diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..3e4ca7db --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @dfinity/dx diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index b1891890..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,11 +0,0 @@ -### Guides - -* Ensure that you have read the [Contributor Guide](blob/main/CONTRIBUTING.md) -* Ensure that you have read the and agreed to the [Code of Conduct](blob/main/CODE_OF_CONDUCT.md) -* Ensure that the title complies with [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/). - -### Describe of Changes - -### Related Issues - -### Describe Testing diff --git a/.github/workflows/README.md b/.github/workflows/README.md deleted file mode 100644 index 835fcb2e..00000000 --- a/.github/workflows/README.md +++ /dev/null @@ -1 +0,0 @@ -# Consider using infrastructure from https://github.com/dfinity/internet-identity for canister tests etc. diff --git a/.github/workflows/rust.yml b/.github/workflows/ci.yml similarity index 66% rename from .github/workflows/rust.yml rename to .github/workflows/ci.yml index d3d8dc58..21ec84ad 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: Rust +name: CI on: pull_request: @@ -8,7 +8,7 @@ on: tags: - v* paths-ignore: - - 'README.md' + - "README.md" jobs: cargo-fmt: runs-on: ubuntu-latest @@ -18,14 +18,7 @@ jobs: - name: Cargo fmt run: | rustup component add rustfmt - cargo fmt - - name: Commit Formatting changes - uses: EndBug/add-and-commit@v9 - if: ${{ github.ref != 'refs/heads/main' }} - with: - add: src - default_author: github_actions - message: "🤖 cargo-fmt auto-update" + cargo fmt --all -- --check cargo-clippy: runs-on: ubuntu-latest @@ -41,25 +34,25 @@ jobs: cargo clippy -- -D clippy::all -D warnings -A clippy::manual_range_contains cargo clippy --tests --benches -- -D clippy::all -D warnings -A clippy::manual_range_contains - cargo-deny: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@master - - run: cargo install --locked cargo-deny - - name: Cargo deny - run: | - cargo-deny check --hide-inclusion-graph || true + # cargo-deny: + # runs-on: ubuntu-latest + # steps: + # - name: Checkout + # uses: actions/checkout@master + # - run: cargo install --locked cargo-deny + # - name: Cargo deny + # run: | + # cargo-deny check --hide-inclusion-graph || true - cargo-audit: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@master - - run: cargo install --locked cargo-audit - - name: Cargo audit - run: | - cargo audit || true + # cargo-audit: + # runs-on: ubuntu-latest + # steps: + # - name: Checkout + # uses: actions/checkout@master + # - run: cargo install --locked cargo-audit + # - name: Cargo audit + # run: | + # cargo audit || true cargo-check: runs-on: ubuntu-latest @@ -98,3 +91,25 @@ jobs: with: command: build args: --tests --release --target wasm32-unknown-unknown + + cargo-test: + runs-on: ubuntu-latest + needs: cargo-check + steps: + - name: Checkout + uses: actions/checkout@master + + - name: Cargo test + uses: actions-rs/cargo@master + with: + command: test + + # candid-check: + # runs-on: ubuntu-latest + # steps: + # - name: Checkout + # uses: actions/checkout@master + # - name: Check interface + # run: | + # cargo run > candid/ic_eth_expected.did + # diff candid/ic_eth.did candid/ic_eth_expected.did diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2c8d0aff --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.dfx/ +target/ diff --git a/iceth-API.md b/API.md similarity index 53% rename from iceth-API.md rename to API.md index eeecf5a3..af967c1c 100644 --- a/iceth-API.md +++ b/API.md @@ -1,54 +1,57 @@ -# IC Eth API +# IC 🔗 ETH (Canister API) -## Overview +## Terminology -*Service, JSON API service:* A Web2 service such as [Infura](https://www.infura.io/), [Gateway.fm](https://gateway.fm/), or [CloudFlare](https://www.cloudflare.com/en-gb/web3/) that offers access to the Ethereum JSON RPC API through HTTP. Note that also other EVM-compatible chains may be covered by such a JSON RPC API. +* `service`: A Web2 service such as [Infura](https://www.infura.io/), [Gateway.fm](https://gateway.fm/), or [CloudFlare](https://www.cloudflare.com/en-gb/web3/) that offers access to the Ethereum JSON RPC API through HTTP. Note that also other EVM-compatible chains may be covered by such a JSON RPC API. +* `network`: An EVM blockchain such as the Ethereum mainnet or Sepolia testnet. +* `chain id`: An EVM network identifier (e.g. `0x1` for the Ethereum mainnet, `0xaa36a7` for the Sepolia testnet). +* `provider`: A provider is registered in the canister and allows for connecting to a specific JSON-RPC service. Each chain id for a particular service requires a different provider and typically requires a different API key. Multiple providers can be registered for a service / chain id combination. -*Provider:* A provider is registered in the canister and allows for connecting to a specific JSON API service in the Web2 world. Each chain id for a particular service requires a different provider and typically requires a different API key. Multiple providers can be registered for a service / chain id combination. - -*Chain id*: The chain id determines the Ethereum / EVM network to connect to. - -## Data Types +View this [reference site](https://chainlist.org/) for a list of available networks and services. ## Methods -### register_provider +### `register_provider` -Register a new *provider* for a Web2-based *service*. +Register a new provider for a Web2-based service. - type RegisterProvider = record { - chain_id: nat64; - service_url: text; - api_key: text; - cycles_per_call: nat64; - cycles_per_message_byte: nat64; - }; +```candid +type RegisterProvider = record { + chain_id: nat64; + base_url: text; + credential_path: text; + cycles_per_call: nat64; + cycles_per_message_byte: nat64; +}; - register_provider: (RegisterProvider) -> (); +register_provider: (RegisterProvider) -> (); +``` The `RegisterProvider` record defines the details about the service to register, including the API key for the service. * `chain_id`: The id of the Ethereum chain this provider allows to connect to. The ids refer to the chain ids as defined for EVM-compatible blockchains, see, e.g., [ChainList](https://chainlist.org/?testnets=true). -* `service_url`: The URLs of the Web2 service provider that is used by the canister when using this provider. -* `api_key`: The API key for authorizing requests to this service provider. The API key is private to the entity registering it and the canister. It is not exposed in the response of the `get_providers` method. The URL used to access the service is constructed by concatenating the `service_url` and the `api_key` (without a seperator), e.g., "https://cloudflare-eth.com" and "/my-api-key"). +* `base_url`: The URLs of the Web2 service provider that is used by the canister when using this provider. +* `credential_path`: A path containing API key for authorizing requests to this service provider. This part of the path is private to the entity registering it and the canister. It is not exposed in the response of the `get_providers` method. The URL used to access the service is constructed by concatenating the `base_url` and the `credential_path` (without a seperator), e.g., "https://cloudflare-eth.com" and "/my-api-key". * `cycles_per_call`: Cycles charged per call by the canister in addition to the base charges when using this provider. * `cycles_per_message_byte`: Cycles charged per payload byte by the canister in addition to the base charges when using this provider. The cycles charged can, for example, be used by the entity providing the API key to amortize the API key costs in the case of commercial API keys. A provider record can be removed by its owner principal or a pricipal with administrative permissions. -### get_providers +### `get_providers` Returns a list of currently registered `RegisteredProvider` entries of the canister. - type RegisteredProvider = record { - provider_id: nat64; - owner: principal; - chain_id: nat64; - service_url: text; - cycles_per_call: nat64; - cycles_per_message_byte: nat64; - }; +```candid +type RegisteredProvider = record { + provider_id: nat64; + owner: principal; + chain_id: nat64; + base_url: text; + cycles_per_call: nat64; + cycles_per_message_byte: nat64; +}; - get_providers: () -> (vec RegisteredProvider) query; +get_providers: () -> (vec RegisteredProvider) query; +``` `vec RegisteredProvider` is a list of providers, each of which represents one provider that has been registered with the canister and corresponds to a registration of an API key for a specific external API service and chain id. A provider entry also contains the cycles price for using this provider, in addition to what is charged for the canister services. @@ -59,45 +62,69 @@ Returns a list of currently registered `RegisteredProvider` entries of the canis * `cycles_per_call`: See `RegisterProvider`. * `cycles_per_message_byte`: See `RegisterProvider`. -Clients of this canister need to select a provider that matches w.r.t. the `chain_id` the network they intend to connect to. If multiple providers are available for a given `chain_id`, the per-message or per-byte price or the entity behind the provider (this can be inferred from the `service_url`) may be factors to choose a suitable provider. +Clients of this canister need to select a provider that matches w.r.t. the `chain_id` the network they intend to connect to. If multiple providers are available for a given `chain_id`, the per-message or per-byte price or the entity behind the provider (this can be inferred from the `base_url`) may be factors to choose a suitable provider. -### json_rpc_request +### `request` Make a request to a Web2 Ethereum node using the caller's URL to an openly available JSON RPC API service, or the caller's URL including an API key for an access-protected API provider. No registered API key of the canister is used in this scenario. - json_rpc_request: (json_rpc_payload: text, service_url: text, max_response_bytes: nat64) -> (EthRpcResult); + request: (service_url: text, json_rpc_payload: text, max_response_bytes: nat64) -> (EthRpcResult); -* `json_rpc_payload`: The payload for the JSON RPC request, in compliance with the [JSON RPC specification](https://www.jsonrpc.org/specification). * `service_url`: The URL of the service, including any API key if required for access-protected services. +* `json_rpc_payload`: The payload for the JSON RPC request, in compliance with the [JSON RPC specification](https://www.jsonrpc.org/specification). * `max_response_bytes`: The expected maximum size of the response of the Web2 API server. This parameter determines the network response size that is charged for. Not specifying it or it being larger than required may lead to substantial extra cycles cost for the HTTPS outcalls mechanism as its (large) default value is used and charged for. * `EthRpcResult`: The response comprises the JSON-encoded result or error, see the corresponding type. -### json_rpc_provider_request +### `provider_request` Make a request to a Web2 Ethereum node using a registered provider for a JSON RPC API service. There is no need for the client to have any established relationship with the API service. - json_rpc_provider_request: (json_rpc_payload: text, provider_id: nat64, max_response_bytes: nat64) -> (EthRpcResult); + provider_request: (provider_id: nat64, json_rpc_payload: text, max_response_bytes: nat64) -> (EthRpcResult); + +* `provider_id`: The id of the registered provider to be used for this call. This uniquely identifies a provider registered with the canister. +* `json_rpc_payload`: See `request`. +* `max_response_bytes`: See `request`. +* `EthRpcResult`: See `request`. + +### `request_cost` + +Calculate the cost of sending a request with the given input arguments. + + request_cost: (service_url: text, json_rpc_payload: text, max_response_bytes: nat64) -> (nat) query; + +* `service_url`: The URL of the service, including any API key if required for access-protected services. +* `json_rpc_payload`: See `request`. +* `max_response_bytes`: See `request`. + +### `provider_request_cost` + +Calculate the cost of sending a request with the given input arguments. + + provider_request_cost: (provider_id: nat64, json_rpc_payload: text, max_response_bytes: nat64) -> (nat) query; -* `json_rpc_payload`: See `json_rpc_request`. * `provider_id`: The id of the registered provider to be used for this call. This uniquely identifies a provider registered with the canister. -* `max_response_bytes`: See `json_rpc_request`. -* `EthRpcResult`: See `json_rpc_request`. +* `json_rpc_payload`: See `request_cost`. +* `max_response_bytes`: See `request_cost`. -### unregister_provider +### `unregister_provider` Unregister a provider from the canister. Only the owner of the provider or an admin principal is authorized to perform this action. - unregister_provider: (provider_id: nat64) -> () ; +```candid +unregister_provider: (provider_id: nat64) -> (); +``` The `provider_id` for the provider to be unregistered is the only parameter required. -### authorize +### `authorize` Used for authorizing a principal for certain classes of actions as defined through `Auth`. - authorize : (principal, Auth) -> (); +```candid +authorize : (principal, Auth) -> (); - type Auth = variant { Rpc; RegisterProvider; Admin }; +type Auth = variant { Rpc; RegisterProvider; Admin }; +``` The `Auth` variant defines the following cases: * `Rpc`: Governs access control to the RPC methods. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 82424b3c..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,133 +0,0 @@ - -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of - any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -https://github.com/orgs/internet-computer-protocol/discussions. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e46e7bef..cdb6e0dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1,103 @@ -# Guide for Contributors +# Contributing + +Thank you for your interest in contributing to this repo. As a member of the community, you are invited and encouraged to contribute by submitting issues, offering suggestions for improvements, adding review comments to existing pull requests, or creating new pull requests to fix issues. + +All contributions to DFINITY documentation and the developer community are respected and appreciated. +Your participation is an important factor in the success of the Internet Computer. + +## Before you contribute + +Before contributing, please take a few minutes to review these contributor guidelines. +The contributor guidelines are intended to make the contribution process easy and effective for everyone involved in addressing your issue, assessing changes, and finalizing your pull requests. + +Before contributing, consider the following: + +- If you want to report an issue, click **Issues**. + +- If you have a general question, post a message to the [community forum](https://forum.dfinity.org/) or submit a [support request](mailto://support@dfinity.org). + +- If you are reporting a bug, provide as much information about the problem as possible. + +- If you want to contribute directly to this repository, typical fixes might include any of the following: + + - Fixes to resolve bugs or documentation errors + - Code improvements + - Feature requests + + Note that any contribution to this repository must be submitted in the form of a **pull request**. + +- If you are creating a pull request, be sure that the pull request only implements one fix or suggestion. + +If you are new to working with GitHub repositories and creating pull requests, consider exploring [First Contributions](https://github.com/firstcontributions/first-contributions) or [How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github). + +# How to make a contribution + +Depending on the type of contribution you want to make, you might follow different workflows. + +This section describes the most common workflow scenarios: + +- Reporting an issue +- Submitting a pull request + +### Reporting an issue + +To open a new issue: + +1. Click **Issues**. + +1. Click **New Issue**. + +1. Click **Open a blank issue**. + +1. Type a title and description, then click **Submit new issue**. + + Be as clear and descriptive as possible. + + For any problem, describe it in detail, including details about the crate, the version of the code you are using, the results you expected, and how the actual results differed from your expectations. + +### Submitting a pull request + +If you want to submit a pull request to fix an issue or add a feature, here's a summary of what you need to do: + +1. Make sure you have a GitHub account, an internet connection, and access to a terminal shell or GitHub Desktop application for running commands. + +2. Navigate to the official repository in a web browser. + +3. Click **Fork** to create a copy of the repository associated with the issue you want to address under your GitHub account or organization name. + +4. Clone the repository to your local machine. + +5. Create a new branch for your fix by running a command similar to the following: + + ```bash + git checkout -b my-branch-name-here + ``` + +6. Open the file you want to fix in a text editor and make the appropriate changes for the issue you are trying to address. + +7. Add the file contents of the changed files to the index `git` uses to manage the state of the project by running a command similar to the following: + + ```bash + git add path-to-changed-file + ``` +8. Commit your changes to store the contents you added to the index along with a descriptive message by running a command similar to the following: + + ```bash + git commit -m "Description of the fix being committed." + ``` + +9. Push the changes to the remote repository by running a command similar to the following: + + ```bash + git push origin my-branch-name-here + ``` + +10. Create a new pull request for the branch you pushed to the upstream GitHub repository. + + Provide a title that includes a short description of the changes made. + +11. Wait for the pull request to be reviewed. + +12. Make changes to the pull request, if requested. + +13. Celebrate your success after your pull request is merged! diff --git a/Cargo.lock b/Cargo.lock index 90b3c2ea..511a5eec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1325,7 +1325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187fa0cecf46628330b7a390a1a65fb0637ea00d3a1121aa847ecbebc0f3ff79" [[package]] -name = "iceth" +name = "ic_eth_rpc" version = "0.1.0" dependencies = [ "candid", diff --git a/Cargo.toml b/Cargo.toml index 3a3f6fa6..d05eea89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] -name = "iceth" +name = "ic_eth_rpc" version = "0.1.0" description = "Ethereum on IC." -authors = ["John Plevyak "] +authors = ["DFINITY Foundation"] readme = "README.md" edition = "2021" diff --git a/Dockerfile b/Dockerfile index a1791e41..375784b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Use this with # -# docker build -t iceth . +# docker build -t ic_eth . # or use ./scripts/docker-build # # The docker image. To update, run `docker pull ubuntu` locally, and update the @@ -45,7 +45,7 @@ COPY . . RUN touch src/main.rs RUN ./scripts/build -RUN sha256sum /iceth.wasm.gz +RUN sha256sum /ic_eth.wasm.gz -FROM scratch AS scratch_iceth -COPY --from=build /iceth.wasm.gz / +FROM scratch AS scratch_ic_eth +COPY --from=build /ic_eth.wasm.gz / diff --git a/NOTICE b/NOTICE deleted file mode 100644 index 1ae43b6b..00000000 --- a/NOTICE +++ /dev/null @@ -1,13 +0,0 @@ -iceth -Copyright 2023 John Plevyak - -This product includes software under these licenses: - ---- - -MIT License - -Copyright (c) 2019 Tomasz Drwięga -Copyright (c) 2022 Rocklabs Team - ---- diff --git a/README.md b/README.md index e0fb2142..dddf6995 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,61 @@ -# IC Eth -ETH for the IC. +# IC 🔗 ETH (Canister) -The IC Eth project realizes a canister smart contract for the Internet Computer blockchain that offers the Ethereum JSON RPC API as an [on-chain API](./iceth-API.md). Requests received on this API by the canister are forwarded to Web2 Ethereum *JSON RPC API services* like [Infura](https://www.infura.io/), [Gateway.fm](https://gateway.fm/), or [CloudFlare](https://www.cloudflare.com/en-gb/web3/) using [HTTPS outcalls](https://internetcomputer.org/docs/current/developer-docs/integrations/http_requests/). This way, the canister acts as a *proxy* to the Web2 world of Ethereum API nodes and simplifies the access to Ethereum JSON RPC API services for canisters. The JSON RPC API exposed by this canister allows a canister smart contract to do much of what a regular Ethereum dApp in the Web2 world could do, e.g., to arbitrarily interact with the Ethereum network, e.g., by querying the state of Ethereum smart contracts or submitting raw transactions to Ethereum. +> #### Interact with the [Ethereum](https://ethereum.org/) blockchain from the [Internet Computer](https://internetcomputer.org/). -This canister provides a convenient, yet effective, connection between the Internet Computer and the Ethereum network. For interactions that involve value transfer, such as in the context of X-chain asset transfers, multiple Web2 JSON RPC providers can be queried by a client to increase the assurance of correctness of the answer. This is a decision on the security model that is left to the client. +### Test canister: [6yxaq-riaaa-aaaap-abkpa-cai](https://a4gq6-oaaaa-aaaab-qaa4q-cai.raw.ic0.app/?id=6yxaq-riaaa-aaaap-abkpa-cai) + +## Overview + +**IC 🔗 ETH** is an Internet Computer canister smart contract for communicating with the Ethereum blockchain using an [on-chain API](./API.md). + +This canister facilitates API requests to Ethereum JSON RPC services such as [Infura](https://www.infura.io/), [Gateway.fm](https://gateway.fm/), or [CloudFlare](https://www.cloudflare.com/en-gb/web3/) using [HTTPS outcalls](https://internetcomputer.org/docs/current/developer-docs/integrations/http_requests/), enabling functionality similar to traditional Ethereum dApps, including querying Ethereum smart contract states and submitting raw transactions. + +Beyond the Ethereum blockchain, this canister also supports Polygon, Avalanche, and other popular EVM networks. Check out [this webpage](https://chainlist.org/) for a list of all supported networks and RPC providers. + +## Quick Start + +Add the following to your `dfx.json` config file: + +```json +{ + "canisters": { + "ic_eth": { + "type": "custom", + "candid": "https://github.com/internet-computer-protocol/ic-eth-rpc/releases/latest/download/ic_eth.did", + "wasm": "https://github.com/internet-computer-protocol/ic-eth-rpc/releases/latest/download/ic_eth_dev.wasm.gz", + "remote": { + "id": { + "ic": "6yxaq-riaaa-aaaap-abkpa-cai" + } + }, + "frontend": {} + } + } +} +``` + +Run the following commands to run the canister in your local environment: + +```sh +# Start the local replica +dfx start --background + +# Deploy the `ic_eth` canister +dfx deploy ic_eth + +# Call the `eth_gasPrice` JSON-RPC method +dfx canister call ic_eth request '("https://cloudflare-eth.com/v1/mainnet", "{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}", 1000)' --wallet $(dfx identity get-wallet) --with-cycles 600000000 +``` + +## How it Works + +This canister provides a connection between the Internet Computer and the Ethereum network. For interactions that involve value transfer, such as in the context of cross-chain asset transfers, multiple Web2 JSON RPC providers can be queried by a client to increase the assurance of correctness of the answer. This is a decision on the security model that is left to the client. Authorized principals are permitted to register, update, and de-register so-called *providers*, each of which defines a registered API key for a specific Web2 JSON API service for a given chain id. It furthermore defines the cycles price to be paid when using this provider. -This canister's API can be used in two different modalities depending on the use case: +This canister's API can be used in two different ways depending on the use case: * *Registered API key:* Client canisters use the canister's RPC API such that canister-registered API keys are used to interact with the Web2 API provider. This has the advantage that the maintainer of the client does not need to manage their own API keys with RPC providers, but simply uses the one registered in the canister. -* *Client-provided API key:* Client canisters can provide their own API key with calls, e.g., to use API providers for which there are no registered providers available. This also helps reduce the quota usage for quota-limited API keys. The API providers to be used this way need to be on an allowlist of the canister. +* *Client-provided API key:* Client canisters can provide their own API key with calls, e.g., to use APIs for which there are no registered providers available. This also helps reduce the quota usage for quota-limited API keys. The API providers to be used this way need to be on an allowlist of the canister. This gives the canister great flexibility of how it can be used in different deployment scenarios. At least the following deployment scenarios are supported by the API of this canister: @@ -20,76 +66,72 @@ At least the following deployment scenarios are supported by the API of this can **Note** The canister has been designed to connect to the Ethereum blockchain from the Internet Computer, however, the canister may also be useful to connect to other EVM blockchains that support the same JSON RPC API and follow standards of Ethereum. -The API of the canister is specified through a [Candid interface specification](./iceth.did). Detailed API documentation is available [here](./iceth-API.md). +The API of the canister is specified through a [Candid interface specification](./ic_eth.did). Detailed API documentation is available [here](./API.md). -## Build +## Contributing -### DFX -```bash -dfx build -``` +Run the following commands to set up a local development environment: -### Docker (reproducable) ```bash -scripts/docker-build +# Clone the repository +git clone https://github.com/internet-computer-protocol/ic-eth-rpc +cd ic-eth-rpc + +# Deploy to the local replica +dfx start --background +dfx deploy ``` ## Examples -### deploy - -The deployment takes a single argument which is the size of the subnet as the cost of an HTTP outcall is proportional to the number of nodes. - +### Ethereum RPC (local replica) ```bash -dfx deploy iceth --argument '(13)' -``` - -### authorization +# Use a custom provider +dfx canister call ic_eth --wallet $(dfx identity get-wallet) --with-cycles 600000000 request '("https://cloudflare-eth.com","{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)' +dfx canister call ic_eth --wallet $(dfx identity get-wallet) --with-cycles 600000000 request '("https://ethereum.publicnode.com","{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)' -```bash -PRINCIPAL=$( dfx identity get-principal ) -dfx canister call iceth authorize "(principal \"$PRINCIPAL\", variant { Rpc })" -dfx canister call iceth get_authorized '(variant { Rpc })' -dfx canister call iceth deauthorize "(principal \"$PRINCIPAL\", variant { Rpc })" +# Register your own provider +dfx canister call ic_eth register_provider '(record { chain_id=1; base_url="https://cloudflare-eth.com"; credential_path="/v1/mainnet"; cycles_per_call=10; cycles_per_message_byte=1; })' +dfx canister call ic_eth --wallet $(dfx identity get-wallet) --with-cycles 600000000 provider_request '(0,"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)' ``` -### local ethereum rpc calls +### Ethereum RPC (IC mainnet) ```bash -dfx canister call --wallet $(dfx identity get-wallet) --with-cycles 600000000 iceth json_rpc_request '("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}","https://cloudflare-eth.com",1000)' -dfx canister call --wallet $(dfx identity get-wallet) --with-cycles 600000000 iceth json_rpc_request '("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}","https://ethereum.publicnode.com",1000)' -dfx canister call iceth register_provider '(record { chain_id=1; service_url="https://cloudflare-eth.com"; api_key="/v1/mainnet"; cycles_per_call=10; cycles_per_message_byte=1; })' -dfx canister call --wallet $(dfx identity get-wallet) --with-cycles 600000000 iceth json_rpc_provider_request '("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",0,1000)' +dfx canister --network ic call ic_eth --wallet $(dfx identity --network ic get-wallet) --with-cycles 600000000 request '("https://cloudflare-eth.com","{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)' +dfx canister --network ic call ic_eth --wallet $(dfx identity --network ic get-wallet) --with-cycles 600000000 request '("https://ethereum.publicnode.com","{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)' ``` -### mainnet ethereum rpc calls +### Authorization (local replica) + ```bash -dfx canister --network ic call --wallet $(dfx identity --network ic get-wallet) --with-cycles 600000000 iceth json_rpc_request '("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}","https://cloudflare-eth.com",1000)' -dfx canister --network ic call --wallet $(dfx identity --network ic get-wallet) --with-cycles 600000000 iceth json_rpc_request '("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}","https://ethereum.publicnode.com",1000)' +PRINCIPAL=$(dfx identity get-principal) +dfx canister call ic_eth authorize "(principal \"$PRINCIPAL\", variant { RegisterProvider })" +dfx canister call ic_eth get_authorized '(variant { RegisterProvider })' +dfx canister call ic_eth deauthorize "(principal \"$PRINCIPAL\", variant { RegisterProvider })" ``` - ## Caveats -### API Keys are stored in the Canister +### API Keys are stored in the canister -Registered API keys are available to IC nodes in plaintext. While the canister memory is not exposed generallly to users, it is available to node providers and to canister controllers. In the future features such as SEV-SNP will enable privacy of canister memory, but until we have those features the API keys should not be considered to be entirely safe from leakage and potential misuse. API key providers should limit the scope of their API keys and monitor usage detect any misuse. +Registered API keys are available to IC nodes in plaintext. While the canister memory is not exposed generallly to users, it is available to node providers and to canister controllers. In the future features such as SEV-SNP will enable privacy of canister memory, but until we have those features the API keys should not be considered to be entirely safe from leakage and potential misuse. API key providers should limit the scope of their API keys and monitor usage to detect any misuse. ### Registered API providers should be aware that each API call will result in one service provider call per node in the subnet and that costs (and payment) is scaled accordingly -Application subnets have some number of nodes (typically 13), so a `json_rpc_request` call will result in 13 HTTP outcalls using the registered API key. API providers should be aware of this when considering rate and operation limits. +Application subnets have some number of nodes (typically 13), so a `request` call will result in 13 HTTP outcalls using the registered API key. API providers should be aware of this when considering rate and operation limits. -### Signed Transactions should be Signed Securely +### Signed Transactions should be securely signed This canister takes pre-signed transactions e.g. for `eth_sendRawTransaction` and these should be signed in a secure way, for example using Threshold ECDSA or by signing the transaction in a secure manner offline. In any case, private keys should not be stored in canisters because canister memory is (currently) not private from node providers. ### JSON is not validated -This canister does not validate the JSON passed to the ETH service. Registered API key providers should be aware of this in case the back end service is vulnerable to a bad JSON/request body. Registered API providers should be aware that there are methods in the ETH RPC API specification which give access to the ETH node keys. Public service providers tend to block these, but registered API providers should ensure that they are not giving access to private keys or other proviledged operations. +This canister does not validate the JSON passed to the ETH service. Registered API key providers should be aware of this in case the back end service is vulnerable to a bad JSON request body. Registered API providers should be aware that there are methods in the ETH RPC API specification which give access to the ETH node keys. Public service providers tend to block these, but registered API providers should ensure that they are not giving access to private keys or other proviledged operations. -### Requests sent to service providers are subject to the service providers privacy policy +### Requests sent to service providers are subject to the service provider's privacy policy Users should be aware of the privacy policy of the service provider to which their requests are sent as some service providers have stronger privacy guarantees. -### Idempotency issues because of one HTTP outcalls per node in the subnet +### Idempotency issues because of one HTTP outcall per node in the subnet HTTP outcalls result in one HTTP outcall per node in the subnet and the results are then combined after filtering through a Transform function. Some ETH RPC calls may not be idempotent or may vary which will cause the call to be reported as a failure as there will be no consensus on the result. In particular `json_rpc_provider_cycles_cost` may be accepted only once and subsequent calls may result in a duplicate error. Furthermore this behavior may differ by service provider. Users should be aware of this and use appropriate caution and/or a different or modified solution. diff --git a/candid/ic_eth.did b/candid/ic_eth.did new file mode 100644 index 00000000..02215c3a --- /dev/null +++ b/candid/ic_eth.did @@ -0,0 +1,45 @@ +type Auth = variant { Rpc; RegisterProvider; FreeRpc; Admin }; +type EthRpcError = variant { + ServiceUrlHostNotAllowed; + HttpRequestError : record { code : nat32; message : text }; + TooFewCycles : text; + ServiceUrlParseError; + ServiceUrlHostMissing; + ProviderNotFound; + NoPermission; +}; +type RegisterProvider = record { + base_url : text; + cycles_per_message_byte : nat64; + chain_id : nat64; + cycles_per_call : nat64; + credential_path : text; +}; +type RegisteredProvider = record { + base_url : text; + owner : principal; + provider_id : nat64; + cycles_per_message_byte : nat64; + chain_id : nat64; + cycles_per_call : nat64; +}; +type Result = variant { Ok : vec nat8; Err : EthRpcError }; +service : { + authorize : (principal, Auth) -> (); + deauthorize : (principal, Auth) -> (); + get_authorized : (Auth) -> (vec text) query; + get_nodes_in_subnet : () -> (nat32) query; + get_open_rpc_access : () -> (bool) query; + get_owed_cycles : (nat64) -> (nat) query; + get_providers : () -> (vec RegisteredProvider) query; + provider_request : (nat64, text, nat64) -> (Result); + provider_request_cost : (nat64, text, nat64) -> (opt nat) query; + register_provider : (RegisterProvider) -> (nat64); + request : (text, text, nat64) -> (Result); + request_cost : (text, text, nat64) -> (nat) query; + set_nodes_in_subnet : (nat32) -> (); + set_open_rpc_access : (bool) -> (); + unregister_provider : (nat64) -> (); + update_provider_credential : (nat64, text) -> (); + withdraw_owed_cycles : (nat64, principal) -> (); +} \ No newline at end of file diff --git a/canister_ids.json b/canister_ids.json new file mode 100644 index 00000000..46f6ad3b --- /dev/null +++ b/canister_ids.json @@ -0,0 +1,5 @@ +{ + "ic_eth": { + "ic": "6yxaq-riaaa-aaaap-abkpa-cai" + } +} \ No newline at end of file diff --git a/dfx.json b/dfx.json index fdb4341f..5376524b 100644 --- a/dfx.json +++ b/dfx.json @@ -1,9 +1,9 @@ { "canisters": { - "iceth": { - "candid": "iceth.did", + "ic_eth": { + "candid": "candid/ic_eth.did", "type": "rust", - "package": "iceth" + "package": "ic_eth_rpc" } }, "version": 1 diff --git a/iceth.did b/iceth.did deleted file mode 100644 index dafc86c1..00000000 --- a/iceth.did +++ /dev/null @@ -1,42 +0,0 @@ -type Auth = variant { Rpc; RegisterProvider; FreeRpc; Admin; }; -type EthRpcResult = variant { - Ok: blob; - Err : opt variant { - NoPermission; - TooFewCycles : text; - ServiceUrlParseError; - ServiceUrlHostMissing; - ServiceUrlHostNotAllowed; - ProviderNotFound; - HttpRequestError : record { code: nat32; message: text }; - } -}; -type RegisteredProvider = record { - provider_id: nat64; - owner: principal; - chain_id: nat64; - service_url: text; - cycles_per_call: nat64; - cycles_per_message_byte: nat64; -}; -type RegisterProvider = record { - chain_id: nat64; - service_url: text; - api_key: text; - cycles_per_call: nat64; - cycles_per_message_byte: nat64; -}; -service : (nat32) -> { - authorize : (principal, Auth) -> (); - deauthorize : (principal, Auth) -> (); - get_authorized : (Auth) -> (vec text) query; - set_open_rpc_access : (bool) -> (); - get_open_rpc_access : () -> (bool) query; - json_rpc_request: (json_rpc_payload: text, service_url: text, max_response_bytes: nat64) -> (EthRpcResult); - json_rpc_provider_request: (json_rpc_payload: text, provider_id: nat64, max_response_bytes: nat64) -> (EthRpcResult); - get_providers: () -> (vec RegisteredProvider) query; - register_provider: (RegisterProvider) -> (); - unregister_provider: (provider_id: nat64) -> (); - get_owed_cycles : (provider_id: nat64) -> (nat) query; - withdraw_owed_cycles : (provider_id: nat64, target_canister_id: principal) -> (); -} diff --git a/scripts/build b/scripts/build index d389751b..99b5a593 100755 --- a/scripts/build +++ b/scripts/build @@ -12,25 +12,25 @@ cd "$SCRIPTS_DIR/.." ######### function title() { - echo "Builds iceth Canister" + echo "Builds ic_eth Canister" } function usage() { cat << EOF Usage: - $0 [--only-dependencies] [--iceth] + $0 [--only-dependencies] [--ic_eth] Options: --only-dependencies only build rust dependencies (no js build, no wasm optimization) - --iceth build the icenthl canister + --ic_eth build the icenthl canister EOF } function help() { cat << EOF -Builds the iceth canister. +Builds the ic_eth canister. NOTE: This requires a working rust toolchain as well as ic-wasm. EOF @@ -53,8 +53,8 @@ do ONLY_DEPS=1 shift ;; - --iceth) - CANISTERS+=("iceth") + --ic_eth) + CANISTERS+=("ic_eth") shift ;; *) @@ -68,7 +68,7 @@ do done if [ ${#CANISTERS[@]} -eq 0 ]; then - CANISTERS=("iceth") + CANISTERS=("ic_eth") fi # Checking for dependencies diff --git a/scripts/docker-build b/scripts/docker-build index 858e5712..c91de502 100755 --- a/scripts/docker-build +++ b/scripts/docker-build @@ -1,6 +1,6 @@ #!/usr/bin/env bash # vim: ft=bash -# Build iceth.wasm inside docker. This outputs iceth.internet_identity.wasm.gz. +# Build ic_eth.wasm inside docker. This outputs ic_eth.internet_identity.wasm.gz. set -euo pipefail @@ -9,7 +9,7 @@ SCRIPTS_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$SCRIPTS_DIR/.." function title() { - echo "Build iceth inside Docker" + echo "Build ic_eth inside Docker" } function usage() { @@ -24,7 +24,7 @@ EOF function help() { cat << EOF -This will create (and override) "./iceth.wasm.gz". +This will create (and override) "./ic_eth.wasm.gz". EOF } @@ -38,7 +38,7 @@ function build() { # image name and build args, made global because they're used in # check_feature() - image_name="iceth-docker-build" + image_name="ic_eth-docker-build" docker_build_args=( --target "scratch_$canister" ) docker_build_args+=(--tag "$image_name" .) @@ -86,7 +86,7 @@ do done if [ ${#CANISTERS[@]} -eq 0 ]; then - CANISTERS=("iceth") + CANISTERS=("ic_eth") fi for canister in "${CANISTERS[@]}" diff --git a/scripts/local b/scripts/local new file mode 100755 index 00000000..9e493d54 --- /dev/null +++ b/scripts/local @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +PRINCIPAL=$(dfx identity get-principal) + +dfx deploy ic_eth --mode reinstall -y +# dfx canister call ic_eth authorize "(principal \"$PRINCIPAL\", variant { Rpc })" +dfx canister call ic_eth authorize "(principal \"$PRINCIPAL\", variant { RegisterProvider })" + +dfx canister call ic_eth register_provider '(record {base_url = "https://cloudflare-eth.com"; credential_path = "/v1/mainnet"; chain_id = 1; cycles_per_call = 1000; cycles_per_message_byte = 100})' + +dfx canister call ic_eth request_cost '("https://cloudflare-eth.com", "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)' +dfx canister call ic_eth request '("https://cloudflare-eth.com", "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)' + +dfx canister call ic_eth provider_request_cost '(0, "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)' +dfx canister call ic_eth provider_request '(0, "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)' diff --git a/src/accounting.rs b/src/accounting.rs new file mode 100644 index 00000000..670b7941 --- /dev/null +++ b/src/accounting.rs @@ -0,0 +1,93 @@ +use crate::*; + +/// Calculate the baseline cost of sending a JSON-RPC request using HTTP outcalls. +pub fn get_request_cost( + json_rpc_payload: &str, + service_url: &str, + max_response_bytes: u64, +) -> u128 { + let nodes_in_subnet = METADATA.with(|m| m.borrow().get().nodes_in_subnet); + let ingress_bytes = + (json_rpc_payload.len() + service_url.len()) as u128 + INGRESS_OVERHEAD_BYTES; + let base_cost = INGRESS_MESSAGE_RECEIVED_COST + + INGRESS_MESSAGE_BYTE_RECEIVED_COST * ingress_bytes + + HTTP_OUTCALL_REQUEST_COST + + HTTP_OUTCALL_BYTE_RECEIEVED_COST * (ingress_bytes + max_response_bytes as u128); + base_cost * (nodes_in_subnet as u128) / BASE_SUBNET_SIZE +} + +/// Calculate the additional cost for calling a registered JSON-RPC provider. +pub fn get_provider_cost(json_rpc_payload: &str, provider: &Provider) -> u128 { + let nodes_in_subnet = METADATA.with(|m| m.borrow().get().nodes_in_subnet); + let cost_per_node = provider.cycles_per_call as u128 + + provider.cycles_per_message_byte as u128 * json_rpc_payload.len() as u128; + cost_per_node * (nodes_in_subnet as u128) +} + +#[test] +fn test_request_cost() { + METADATA.with(|m| { + let mut metadata = m.borrow().get().clone(); + metadata.nodes_in_subnet = 13; + m.borrow_mut().set(metadata).unwrap(); + }); + + let base_cost = get_request_cost( + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}", + "https://cloudflare-eth.com", + 1000, + ); + let s10 = "0123456789"; + let base_cost_s10 = get_request_cost( + &("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}".to_string() + + s10), + "https://cloudflare-eth.com", + 1000, + ); + assert_eq!( + base_cost + 10 * (INGRESS_MESSAGE_BYTE_RECEIVED_COST + HTTP_OUTCALL_BYTE_RECEIEVED_COST), + base_cost_s10 + ) +} + +#[test] +fn test_provider_cost() { + METADATA.with(|m| { + let mut metadata = m.borrow().get().clone(); + metadata.nodes_in_subnet = 13; + m.borrow_mut().set(metadata).unwrap(); + }); + + let provider = Provider { + provider_id: 0, + base_url: "".to_string(), + credential_path: "".to_string(), + owner: Principal::anonymous(), + chain_id: 1, + cycles_owed: 0, + cycles_per_call: 0, + cycles_per_message_byte: 2, + }; + let base_cost = get_provider_cost( + "{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}", + &provider, + ); + + let provider_s10 = Provider { + provider_id: 0, + base_url: "".to_string(), + credential_path: "".to_string(), + owner: Principal::anonymous(), + chain_id: 1, + cycles_owed: 0, + cycles_per_call: 1000, + cycles_per_message_byte: 2, + }; + let s10 = "0123456789"; + let base_cost_s10 = get_provider_cost( + &("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}".to_string() + + s10), + &provider_s10, + ); + assert_eq!(base_cost + (10 * 2 + 1000) * 13, base_cost_s10) +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 00000000..86060fde --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,93 @@ +use candid::Principal; + +use crate::{Auth, PrincipalStorable, AUTH, AUTH_STABLE, METADATA}; + +pub fn is_authorized(auth: Auth) -> bool { + is_authorized_principal(&ic_cdk::caller(), auth) +} + +pub fn is_authorized_principal(principal: &Principal, auth: Auth) -> bool { + if auth == Auth::Rpc && METADATA.with(|m| m.borrow().get().open_rpc_access) { + return true; + } + AUTH.with(|a| { + if let Some(v) = a.borrow().get(&PrincipalStorable(*principal)) { + (v & (auth as u32)) != 0 + } else { + false + } + }) +} + +pub fn require_admin_or_controller() -> Result<(), String> { + let caller = ic_cdk::caller(); + if is_authorized_principal(&caller, Auth::Admin) || ic_cdk::api::is_controller(&caller) { + Ok(()) + } else { + Err("You are not authorized".to_string()) + } +} + +pub fn require_register_provider() -> Result<(), String> { + if is_authorized(Auth::RegisterProvider) { + Ok(()) + } else { + Err("You are not authorized".to_string()) + } +} + +pub fn require_stable_authorized() -> Result<(), String> { + AUTH_STABLE.with(|a| { + if ic_cdk::api::is_controller(&ic_cdk::caller()) || a.borrow().contains(&ic_cdk::caller()) { + Ok(()) + } else { + Err("You are not stable authorized".to_string()) + } + }) +} + +pub fn do_authorize(principal: Principal, auth: Auth) { + AUTH.with(|a| { + let mut auth_map = a.borrow_mut(); + let principal = PrincipalStorable(principal); + if let Some(v) = auth_map.get(&principal) { + auth_map.insert(principal, v | (auth as u32)); + } else { + auth_map.insert(principal, auth as u32); + } + }); +} + +pub fn do_deauthorize(principal: Principal, auth: Auth) { + AUTH.with(|a| { + let mut auth_map = a.borrow_mut(); + let principal = PrincipalStorable(principal); + if let Some(v) = auth_map.get(&principal) { + auth_map.insert(principal, v & !(auth as u32)); + } + }); +} + +#[test] +fn test_authorization() { + let principal1 = + Principal::from_text("k5dlc-ijshq-lsyre-qvvpq-2bnxr-pb26c-ag3sc-t6zo5-rdavy-recje-zqe") + .unwrap(); + let principal2 = + Principal::from_text("yxhtl-jlpgx-wqnzc-ysego-h6yqe-3zwfo-o3grn-gvuhm-nz3kv-ainub-6ae") + .unwrap(); + assert!(!is_authorized_principal(&principal1, Auth::Rpc)); + assert!(!is_authorized_principal(&principal2, Auth::Rpc)); + + do_authorize(principal1, Auth::Rpc); + assert!(is_authorized_principal(&principal1, Auth::Rpc)); + assert!(!is_authorized_principal(&principal2, Auth::Rpc)); + + do_deauthorize(principal1, Auth::Rpc); + assert!(!is_authorized_principal(&principal1, Auth::Rpc)); + assert!(!is_authorized_principal(&principal2, Auth::Rpc)); + + do_authorize(principal1, Auth::RegisterProvider); + assert!(!is_authorized_principal(&principal1, Auth::Admin)); + assert!(is_authorized_principal(&principal1, Auth::RegisterProvider)); +} diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 00000000..c398053f --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,51 @@ +pub const INGRESS_OVERHEAD_BYTES: u128 = 100; +pub const INGRESS_MESSAGE_RECEIVED_COST: u128 = 1_200_000; +pub const INGRESS_MESSAGE_BYTE_RECEIVED_COST: u128 = 2_000; +pub const HTTP_OUTCALL_REQUEST_COST: u128 = 400_000_000; +pub const HTTP_OUTCALL_BYTE_RECEIEVED_COST: u128 = 100_000; +pub const BASE_SUBNET_SIZE: u128 = 13; // App subnet + +pub const MINIMUM_WITHDRAWAL_CYCLES: u128 = 1_000_000_000; + +pub const STRING_STORABLE_MAX_SIZE: u32 = 100; +pub const WASM_PAGE_SIZE: u64 = 65536; + +pub const INITIAL_SERVICE_HOSTS_ALLOWLIST: &[&str] = &[ + "cloudflare-eth.com", + "ethereum.publicnode.com", + "eth-mainnet.g.alchemy.com", + "eth-goerli.g.alchemy.com", + "rpc.flashbots.net", + "eth-mainnet.blastapi.io", + "ethereumnodelight.app.runonflux.io", + "eth.nownodes.io", + "rpc.ankr.com", + "mainnet.infura.io", + "eth.getblock.io", + "rpc.kriptonio.com", + "api.0x.org", + "erigon-mainnet--rpc.datahub.figment.io", + "archivenode.io", + "eth-mainnet.nodereal.io", + "ethereum-mainnet.s.chainbase.online", + "eth.llamarpc.com", + "ethereum-mainnet-rpc.allthatnode.com", + "api.zmok.io", + "in-light.eth.linkpool.iono", + "api.mycryptoapi.com", + "mainnet.eth.cloud.ava.dono", + "eth-mainnet.gateway.pokt.network", +]; + +// Static permissions. The canister creator is also authorized for all permissions. + +// Principals allowed to send JSON RPCs. +pub const DEFAULT_NODES_IN_SUBNET: u32 = 13; +pub const DEFAULT_OPEN_RPC_ACCESS: bool = true; +pub const RPC_ALLOWLIST: &[&str] = &[]; +// Principals allowed to registry API keys. +pub const REGISTER_PROVIDER_ALLOWLIST: &[&str] = &[]; +// Principals that will not be charged cycles to send JSON RPCs. +pub const FREE_RPC_ALLOWLIST: &[&str] = &[]; +// Principals who have Admin authorization. +pub const AUTHORIZED_ADMIN: &[&str] = &[]; diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 00000000..b998e05b --- /dev/null +++ b/src/http.rs @@ -0,0 +1,90 @@ +use ic_canister_log::log; +use ic_cdk::api::management_canister::http_request::{ + http_request as make_http_request, CanisterHttpRequestArgument, HttpHeader, HttpMethod, + TransformContext, +}; + +use crate::*; + +pub async fn do_http_request( + source: ResolvedSource, + json_rpc_payload: &str, + max_response_bytes: u64, +) -> Result, EthRpcError> { + inc_metric!(requests); + if !is_authorized(Auth::Rpc) { + inc_metric!(request_err_no_permission); + return Err(EthRpcError::NoPermission); + } + let cycles_available = ic_cdk::api::call::msg_cycles_available128(); + let (service_url, provider) = match source { + ResolvedSource::Url(url) => (url, None), + ResolvedSource::Provider(provider) => (provider.service_url(), Some(provider)), + }; + let parsed_url = url::Url::parse(&service_url).or(Err(EthRpcError::ServiceUrlParseError))?; + let host = parsed_url + .host_str() + .ok_or(EthRpcError::ServiceUrlHostMissing)? + .to_string(); + if SERVICE_HOSTS_ALLOWLIST.with(|a| !a.borrow().contains(&host.as_str())) { + log!(INFO, "host not allowed: {}", host); + inc_metric!(request_err_host_not_allowed); + return Err(EthRpcError::ServiceUrlHostNotAllowed); + } + let request_cost = get_request_cost(json_rpc_payload, &service_url, max_response_bytes); + let provider_cost = provider + .as_ref() + .map_or(0, |provider| get_provider_cost(json_rpc_payload, provider)); + let cost = request_cost + provider_cost; + if !is_authorized(Auth::FreeRpc) { + if cycles_available < cost { + return Err(EthRpcError::TooFewCycles(format!( + "requires {cost} cycles, got {cycles_available} cycles", + ))); + } + ic_cdk::api::call::msg_cycles_accept128(cost); + if let Some(mut provider) = provider { + provider.cycles_owed += provider_cost; + PROVIDERS.with(|p| { + // Error should not happen here as it was checked before. + p.borrow_mut() + .insert(provider.provider_id, provider) + .expect("unable to update Provider"); + }); + } + add_metric!(request_cycles_charged, cost); + add_metric!(request_cycles_refunded, cycles_available - cost); + } + inc_metric_entry!(host_requests, host); + let request_headers = vec![ + HttpHeader { + name: "Content-Type".to_string(), + value: "application/json".to_string(), + }, + HttpHeader { + name: "Host".to_string(), + value: host.to_string(), + }, + ]; + let request = CanisterHttpRequestArgument { + url: service_url, + max_response_bytes: Some(max_response_bytes), + method: HttpMethod::POST, + headers: request_headers, + body: Some(json_rpc_payload.as_bytes().to_vec()), + transform: Some(TransformContext::from_name( + "__transform_json_rpc".to_string(), + vec![], + )), + }; + match make_http_request(request, cost).await { + Ok((result,)) => Ok(result.body), + Err((r, m)) => { + inc_metric!(request_err_http); + Err(EthRpcError::HttpRequestError { + code: r as u32, + message: m, + }) + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 00000000..95220407 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,21 @@ +pub use candid::Principal; + +mod accounting; +mod auth; +mod constants; +mod http; +mod memory; +mod metrics; +mod types; +mod util; +mod validate; + +pub use crate::accounting::*; +pub use crate::auth::*; +pub use crate::constants::*; +pub use crate::http::*; +pub use crate::memory::*; +pub use crate::metrics::*; +pub use crate::types::*; +pub use crate::util::*; +pub use crate::validate::*; diff --git a/src/main.rs b/src/main.rs index b19f1fea..070e1b58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,416 +1,73 @@ -use candid::{candid_method, CandidType, Decode, Deserialize, Encode, Principal}; -use ic_canister_log::{declare_log_buffer, log}; +use candid::{candid_method, CandidType}; +use ic_canister_log::log; use ic_canisters_http_types::{ HttpRequest as AssetHttpRequest, HttpResponse as AssetHttpResponse, HttpResponseBuilder, }; -use ic_cdk::api::management_canister::http_request::{ - http_request as make_http_request, CanisterHttpRequestArgument, HttpHeader, HttpMethod, - HttpResponse, TransformArgs, TransformContext, -}; +use ic_cdk::api::management_canister::http_request::{HttpHeader, HttpResponse, TransformArgs}; +use ic_cdk_macros::{query, update}; use ic_nervous_system_common::{serve_logs, serve_logs_v2, serve_metrics}; -#[cfg(not(target_arch = "wasm32"))] -use ic_stable_structures::file_mem::FileMemory; -use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; -#[cfg(target_arch = "wasm32")] -use ic_stable_structures::DefaultMemoryImpl; -use ic_stable_structures::{BoundedStorable, Cell, StableBTreeMap, Storable}; -#[macro_use] -extern crate num_derive; -use std::borrow::Cow; -use std::cell::RefCell; -use std::collections::hash_set::HashSet; -use std::collections::HashMap; - -const INGRESS_OVERHEAD_BYTES: u128 = 100; -const INGRESS_MESSAGE_RECEIVED_COST: u128 = 1_200_000u128; -const INGRESS_MESSAGE_BYTE_RECEIVED_COST: u128 = 2_000u128; -const HTTP_OUTCALL_REQUEST_COST: u128 = 400_000_000u128; -const HTTP_OUTCALL_BYTE_RECEIEVED_COST: u128 = 100_000u128; -const BASE_SUBNET_SIZE: u128 = 13; // App subnet - -const MINIMUM_WITHDRAWAL_CYCLES: u128 = 1_000_000_000u128; - -const STRING_STORABLE_MAX_SIZE: u32 = 100; -const WASM_PAGE_SIZE: u64 = 65536; - -const INITIAL_SERVICE_HOSTS_ALLOWLIST: &[&str] = &[ - "cloudflare-eth.com", - "ethereum.publicnode.com", - "eth-mainnet.g.alchemy.com", - "eth-goerli.g.alchemy.com", - "rpc.flashbots.net", - "eth-mainnet.blastapi.io", - "ethereumnodelight.app.runonflux.io", - "eth.nownodes.io", - "rpc.ankr.com", - "mainnet.infura.io", - "eth.getblock.io", - "rpc.kriptonio.com", - "api.0x.org", - "erigon-mainnet--rpc.datahub.figment.io", - "archivenode.io", - "eth-mainnet.nodereal.io", - "ethereum-mainnet.s.chainbase.online", - "eth.llamarpc.com", - "ethereum-mainnet-rpc.allthatnode.com", - "api.zmok.io", - "in-light.eth.linkpool.iono", - "api.mycryptoapi.com", - "mainnet.eth.cloud.ava.dono", - "eth-mainnet.gateway.pokt.network", -]; - -// Static permissions. The canister creator is also authorized for all permissions. - -// Principals allowed to send JSON RPCs. -const OPEN_RPC_ACCESS: bool = true; -const RPC_ALLOWLIST: &[&str] = &[]; -// Principals allowed to registry API keys. -const REGISTER_PROVIDER_ALLOWLIST: &[&str] = &[]; -// Principals that will not be charged cycles to send JSON RPCs. -const FREE_RPC_ALLOWLIST: &[&str] = &[]; -// Principals who have Admin authorization. -const AUTHORIZED_ADMIN: &[&str] = &[]; - -type AllowlistSet = HashSet<&'static &'static str>; - -#[allow(unused)] // Some compiler quirk causes this to be reported as unused. -#[cfg(not(target_arch = "wasm32"))] -type Memory = VirtualMemory; -#[cfg(target_arch = "wasm32")] -type Memory = VirtualMemory; - -declare_log_buffer!(name = INFO, capacity = 1000); -declare_log_buffer!(name = ERROR, capacity = 1000); - -#[derive(Default)] -struct Metrics { - json_rpc_requests: u64, - json_rpc_request_cycles_charged: u128, - json_rpc_request_cycles_refunded: u128, - json_rpc_request_err_no_permission: u64, - json_rpc_request_err_service_url_host_not_allowed: u64, - json_rpc_request_err_http_request_error: u64, - json_rpc_host_requests: HashMap, -} - -// These need to be powers of two so that they can be used as bit fields. -#[derive(Clone, Debug, PartialEq, CandidType, FromPrimitive, Deserialize)] -enum Auth { - Admin = 0b0001, - Rpc = 0b0010, - RegisterProvider = 0b0100, - FreeRpc = 0b1000, -} - -#[derive(Clone, Debug, Default, CandidType, Deserialize)] -struct Metadata { - nodes_in_subnet: u32, - next_provider_id: u64, - open_rpc_access: bool, -} - -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -struct StringStorable(String); - -#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] -struct PrincipalStorable(Principal); - -impl Storable for StringStorable { - fn to_bytes(&self) -> std::borrow::Cow<[u8]> { - // String already implements `Storable`. - self.0.to_bytes() - } - - fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self { - Self(String::from_bytes(bytes)) - } -} - -impl BoundedStorable for StringStorable { - const MAX_SIZE: u32 = STRING_STORABLE_MAX_SIZE; - const IS_FIXED_SIZE: bool = false; -} - -impl Storable for PrincipalStorable { - fn to_bytes(&self) -> std::borrow::Cow<[u8]> { - std::borrow::Cow::from(self.0.as_slice()) - } - - fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self { - Self(Principal::from_slice(&bytes)) - } -} - -impl BoundedStorable for PrincipalStorable { - const MAX_SIZE: u32 = 29; - const IS_FIXED_SIZE: bool = false; -} - -#[derive(Debug, CandidType)] -struct RegisteredProvider { - provider_id: u64, - owner: Principal, - chain_id: u64, - service_url: String, - cycles_per_call: u64, - cycles_per_message_byte: u64, -} - -#[derive(Debug, CandidType, Deserialize)] -struct RegisterProvider { - chain_id: u64, - service_url: String, - api_key: String, - cycles_per_call: u64, - cycles_per_message_byte: u64, -} - -#[derive(Clone, Debug, CandidType, Deserialize)] -struct Provider { - provider_id: u64, - owner: Principal, - chain_id: u64, - service_url: String, - api_key: String, - cycles_per_call: u64, - cycles_per_message_byte: u64, - cycles_owed: u128, -} - -impl Storable for Metadata { - fn to_bytes(&self) -> std::borrow::Cow<[u8]> { - Cow::Owned(Encode!(self).unwrap()) - } - fn from_bytes(bytes: Cow<[u8]>) -> Self { - Decode!(&bytes, Self).unwrap() - } -} - -impl Storable for Provider { - fn to_bytes(&self) -> std::borrow::Cow<[u8]> { - Cow::Owned(Encode!(self).unwrap()) - } - fn from_bytes(bytes: Cow<[u8]>) -> Self { - Decode!(&bytes, Self).unwrap() - } -} - -impl BoundedStorable for Provider { - const MAX_SIZE: u32 = 256; // A reasonable limit. - const IS_FIXED_SIZE: bool = false; -} - -thread_local! { - // Transient static data: this is reset when the canister is upgraded. - static METRICS: RefCell = RefCell::new(Metrics::default()); - static SERVICE_HOSTS_ALLOWLIST: RefCell = RefCell::new(AllowlistSet::new()); - static AUTH_STABLE: RefCell> = RefCell::new(HashSet::::new()); - - // Stable static data: this is preserved when the canister is upgraded. - #[cfg(not(target_arch = "wasm32"))] - static MEMORY_MANAGER: RefCell> = - RefCell::new(MemoryManager::init(FileMemory::new(std::fs::OpenOptions::new().read(true).write(true).create(true).open("stable_memory.bin").unwrap()))); - #[cfg(target_arch = "wasm32")] - static MEMORY_MANAGER: RefCell> = - RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); - static METADATA: RefCell> = RefCell::new(Cell::init( - MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))), - ::default()).unwrap()); - static AUTH: RefCell> = RefCell::new( - StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))))); - static PROVIDERS: RefCell> = RefCell::new( - StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(2))))); -} - -#[derive(CandidType, Debug)] -enum EthRpcError { - NoPermission, - TooFewCycles(String), - ServiceUrlParseError, - ServiceUrlHostMissing, - ServiceUrlHostNotAllowed, - ProviderNotFound, - HttpRequestError { code: u32, message: String }, -} - -#[macro_export] -macro_rules! inc_metric { - ($metric:ident) => {{ - METRICS.with(|m| m.borrow_mut().$metric += 1); - }}; -} -#[macro_export] -macro_rules! inc_metric_entry { - ($metric:ident, $entry:expr) => {{ - METRICS.with(|m| { - m.borrow_mut() - .$metric - .entry($entry.clone()) - .and_modify(|counter| *counter += 1) - .or_insert(1); - }); - }}; -} - -#[macro_export] -macro_rules! add_metric { - ($metric:ident, $value:expr) => {{ - METRICS.with(|m| m.borrow_mut().$metric += $value); - }}; -} +use ic_eth_rpc::*; -#[macro_export] -macro_rules! get_metric { - ($metric:ident) => {{ - METRICS.with(|m| m.borrow().$metric) - }}; -} - -#[ic_cdk_macros::update] +#[update] #[candid_method] -async fn json_rpc_request( - json_rpc_payload: String, +async fn request( service_url: String, + json_rpc_payload: String, max_response_bytes: u64, ) -> Result, EthRpcError> { - json_rpc_request_internal(json_rpc_payload, service_url, max_response_bytes, None).await + do_http_request( + ResolvedSource::Url(service_url), + &json_rpc_payload, + max_response_bytes, + ) + .await } -#[ic_cdk_macros::update] +#[update] #[candid_method] -async fn json_rpc_provider_request( - json_rpc_payload: String, +async fn provider_request( provider_id: u64, + json_rpc_payload: String, max_response_bytes: u64, ) -> Result, EthRpcError> { let provider = PROVIDERS.with(|p| { p.borrow() .get(&provider_id) .ok_or(EthRpcError::ProviderNotFound) - }); - let provider = provider?; - let service_url = provider.service_url.clone() + &provider.api_key; - json_rpc_request_internal( - json_rpc_payload, - service_url, + })?; + do_http_request( + ResolvedSource::Provider(provider), + &json_rpc_payload, max_response_bytes, - Some(provider), ) .await } -async fn json_rpc_request_internal( - json_rpc_payload: String, - service_url: String, - max_response_bytes: u64, - provider: Option, -) -> Result, EthRpcError> { - inc_metric!(json_rpc_requests); - if !is_authorized(Auth::Rpc) { - inc_metric!(json_rpc_request_err_no_permission); - return Err(EthRpcError::NoPermission); - } - let cycles_available = ic_cdk::api::call::msg_cycles_available128(); - let parsed_url = url::Url::parse(&service_url).or(Err(EthRpcError::ServiceUrlParseError))?; - let host = parsed_url - .host_str() - .ok_or(EthRpcError::ServiceUrlHostMissing)? - .to_string(); - if SERVICE_HOSTS_ALLOWLIST.with(|a| !a.borrow().contains(&host.as_str())) { - log!(INFO, "host not allowed {}", host); - inc_metric!(json_rpc_request_err_service_url_host_not_allowed); - return Err(EthRpcError::ServiceUrlHostNotAllowed); - } - let provider_cost = match &provider { - None => 0, - Some(provider) => json_rpc_provider_cycles_cost( - &json_rpc_payload, - provider.cycles_per_call, - provider.cycles_per_message_byte, - ), - }; - let cost = - json_rpc_cycles_cost(&json_rpc_payload, &service_url, max_response_bytes) + provider_cost; - if !is_authorized(Auth::FreeRpc) { - if cycles_available < cost { - return Err(EthRpcError::TooFewCycles(format!( - "requires {} cycles, got {} cycles", - cost, cycles_available - ))); - } - ic_cdk::api::call::msg_cycles_accept128(cost); - if let Some(mut provider) = provider { - provider.cycles_owed += provider_cost; - PROVIDERS.with(|p| { - // Error should not happen here as it was checked before. - p.borrow_mut() - .insert(provider.provider_id, provider) - .expect("unable to update Provider"); - }); - } - add_metric!(json_rpc_request_cycles_charged, cost); - add_metric!(json_rpc_request_cycles_refunded, cycles_available - cost); - } - inc_metric_entry!(json_rpc_host_requests, host); - let request_headers = vec![ - HttpHeader { - name: "Content-Type".to_string(), - value: "application/json".to_string(), - }, - HttpHeader { - name: "Host".to_string(), - value: host.to_string(), - }, - ]; - let request = CanisterHttpRequestArgument { - url: service_url, - max_response_bytes: Some(max_response_bytes), - method: HttpMethod::POST, - headers: request_headers, - body: Some(json_rpc_payload.as_bytes().to_vec()), - transform: Some(TransformContext::from_name("transform".to_string(), vec![])), - }; - match make_http_request(request, cost).await { - Ok((result,)) => Ok(result.body), - Err((r, m)) => { - inc_metric!(json_rpc_request_err_http_request_error); - Err(EthRpcError::HttpRequestError { - code: r as u32, - message: m, - }) - } - } +#[query] +#[candid_method(query)] +fn request_cost(service_url: String, json_rpc_payload: String, max_response_bytes: u64) -> u128 { + get_request_cost(&json_rpc_payload, &service_url, max_response_bytes) } -fn json_rpc_cycles_cost( - json_rpc_payload: &str, - service_url: &str, +#[query] +#[candid_method(query)] +fn provider_request_cost( + provider_id: u64, + json_rpc_payload: String, max_response_bytes: u64, -) -> u128 { - let nodes_in_subnet = METADATA.with(|m| m.borrow().get().nodes_in_subnet); - let ingress_bytes = - (json_rpc_payload.len() + service_url.len()) as u128 + INGRESS_OVERHEAD_BYTES; - let base_cost = INGRESS_MESSAGE_RECEIVED_COST - + INGRESS_MESSAGE_BYTE_RECEIVED_COST * ingress_bytes - + HTTP_OUTCALL_REQUEST_COST - + HTTP_OUTCALL_BYTE_RECEIEVED_COST * (ingress_bytes + max_response_bytes as u128); - base_cost * (nodes_in_subnet as u128) / BASE_SUBNET_SIZE -} - -fn json_rpc_provider_cycles_cost( - json_rpc_payload: &str, - provider_cycles_per_call: u64, - provider_cycles_per_message_byte: u64, -) -> u128 { - let nodes_in_subnet = METADATA.with(|m| m.borrow().get().nodes_in_subnet); - let base_cost = provider_cycles_per_call as u128 - + provider_cycles_per_message_byte as u128 * json_rpc_payload.len() as u128; - base_cost * (nodes_in_subnet as u128) +) -> Option { + let provider = PROVIDERS.with(|p| p.borrow().get(&provider_id))?; + let request_cost = get_request_cost( + &json_rpc_payload, + &provider.service_url(), + max_response_bytes, + ); + let provider_cost = get_provider_cost(&json_rpc_payload, &provider); + Some(request_cost + provider_cost) } -#[ic_cdk::query] +#[query] #[candid_method(query)] fn get_providers() -> Vec { PROVIDERS.with(|p| { @@ -420,7 +77,7 @@ fn get_providers() -> Vec { provider_id: e.provider_id, owner: e.owner, chain_id: e.chain_id, - service_url: e.service_url, + base_url: e.base_url, cycles_per_call: e.cycles_per_call, cycles_per_message_byte: e.cycles_per_message_byte, }) @@ -428,14 +85,13 @@ fn get_providers() -> Vec { }) } -#[ic_cdk::update(guard = "is_authorized_register_provider")] +#[update(guard = "require_register_provider")] #[candid_method] -fn register_provider(provider: RegisterProvider) { - let parsed_url = url::Url::parse(&provider.service_url).expect("unable to parse service_url"); +fn register_provider(provider: RegisterProvider) -> u64 { + let parsed_url = url::Url::parse(&provider.base_url).expect("unable to parse service_url"); let host = parsed_url.host_str().expect("service_url host missing"); - if SERVICE_HOSTS_ALLOWLIST.with(|a| !a.borrow().contains(&host)) { - ic_cdk::trap("service_url host not allowed"); - } + validate_base_url(host); + validate_credential_path(&provider.credential_path); let provider_id = METADATA.with(|m| { let mut metadata = m.borrow().get().clone(); metadata.next_provider_id += 1; @@ -449,17 +105,34 @@ fn register_provider(provider: RegisterProvider) { provider_id, owner: ic_cdk::caller(), chain_id: provider.chain_id, - service_url: provider.service_url, - api_key: provider.api_key, + base_url: provider.base_url, + credential_path: provider.credential_path, cycles_per_call: provider.cycles_per_call, cycles_per_message_byte: provider.cycles_per_message_byte, cycles_owed: 0, }, ) }); + provider_id +} + +#[update(guard = "require_register_provider")] +#[candid_method] +fn update_provider_credential(provider_id: u64, credential_path: String) { + validate_credential_path(&credential_path); + PROVIDERS.with(|p| match p.borrow_mut().get(&provider_id) { + Some(mut provider) => { + if provider.owner != ic_cdk::caller() && !is_authorized(Auth::Admin) { + ic_cdk::trap("Provider owner != caller"); + } + provider.credential_path = credential_path; + p.borrow_mut().insert(provider_id, provider); + } + None => ic_cdk::trap("Provider not found"), + }); } -#[ic_cdk::update(guard = "is_authorized_register_provider")] +#[update(guard = "require_register_provider")] #[candid_method] fn unregister_provider(provider_id: u64) { PROVIDERS.with(|p| { @@ -473,7 +146,7 @@ fn unregister_provider(provider_id: u64) { }); } -#[ic_cdk::query(guard = "is_authorized_register_provider")] +#[query(guard = "require_register_provider")] #[candid_method(query)] fn get_owed_cycles(provider_id: u64) -> u128 { let provider = PROVIDERS.with(|p| { @@ -493,7 +166,7 @@ struct DepositCyclesArgs { canister_id: Principal, } -#[ic_cdk::update(guard = "is_authorized_register_provider")] +#[update(guard = "require_register_provider")] #[candid_method] async fn withdraw_owed_cycles(provider_id: u64, canister_id: Principal) { let provider = PROVIDERS.with(|p| { @@ -546,7 +219,7 @@ async fn withdraw_owed_cycles(provider_id: u64, canister_id: Principal) { }; } -#[ic_cdk_macros::query(name = "transform")] +#[query(name = "__transform_json_rpc")] fn transform(args: TransformArgs) -> HttpResponse { HttpResponse { status: args.response.status.clone(), @@ -557,52 +230,24 @@ fn transform(args: TransformArgs) -> HttpResponse { } } -#[ic_cdk_macros::init] -fn init(nodes_in_subnet: u32) { +#[ic_cdk::init] +fn init() { initialize(); METADATA.with(|m| { let mut metadata = m.borrow().get().clone(); - metadata.nodes_in_subnet = nodes_in_subnet; - metadata.open_rpc_access = OPEN_RPC_ACCESS; + metadata.nodes_in_subnet = DEFAULT_NODES_IN_SUBNET; + metadata.open_rpc_access = DEFAULT_OPEN_RPC_ACCESS; m.borrow_mut().set(metadata).unwrap(); }); } -#[ic_cdk_macros::post_upgrade] +#[ic_cdk::post_upgrade] fn post_upgrade() { initialize(); stable_authorize(ic_cdk::caller()); } -fn initialize() { - SERVICE_HOSTS_ALLOWLIST - .with(|a| (*a.borrow_mut()) = AllowlistSet::from_iter(INITIAL_SERVICE_HOSTS_ALLOWLIST)); - - for principal in RPC_ALLOWLIST.iter() { - authorize(to_principal(principal), Auth::Rpc); - } - for principal in REGISTER_PROVIDER_ALLOWLIST.iter() { - authorize(to_principal(principal), Auth::RegisterProvider); - } - for principal in FREE_RPC_ALLOWLIST.iter() { - authorize(to_principal(principal), Auth::FreeRpc); - } - for principal in AUTHORIZED_ADMIN.iter() { - authorize(to_principal(principal), Auth::Admin); - } -} - -fn to_principal(principal: &str) -> Principal { - match Principal::from_text(principal) { - Ok(p) => p, - Err(e) => ic_cdk::trap(&format!( - "failed to convert Principal {} {:?}", - principal, e - )), - } -} - -#[ic_cdk::query] +#[query] fn http_request(request: AssetHttpRequest) -> AssetHttpResponse { match request.path() { "/metrics" => serve_metrics(encode_metrics), @@ -613,27 +258,17 @@ fn http_request(request: AssetHttpRequest) -> AssetHttpResponse { } } -fn is_stable_authorized() -> Result<(), String> { - AUTH_STABLE.with(|a| { - if ic_cdk::api::is_controller(&ic_cdk::caller()) || a.borrow().contains(&ic_cdk::caller()) { - Ok(()) - } else { - Err("You are not stable authorized".to_string()) - } - }) -} - -#[ic_cdk_macros::update(guard = "is_stable_authorized")] +#[update(guard = "require_stable_authorized")] fn stable_authorize(principal: Principal) { AUTH_STABLE.with(|a| a.borrow_mut().insert(principal)); } -#[ic_cdk_macros::query(guard = "is_stable_authorized")] +#[query(guard = "require_stable_authorized")] fn stable_size() -> u64 { ic_cdk::api::stable::stable64_size() * WASM_PAGE_SIZE } -#[ic_cdk_macros::query(guard = "is_stable_authorized")] +#[query(guard = "require_stable_authorized")] fn stable_read(offset: u64, length: u64) -> Vec { let mut buffer = Vec::new(); buffer.resize(length as usize, 0); @@ -641,7 +276,7 @@ fn stable_read(offset: u64, length: u64) -> Vec { buffer } -#[ic_cdk_macros::update(guard = "is_stable_authorized")] +#[update(guard = "require_stable_authorized")] fn stable_write(offset: u64, buffer: Vec) { let size = offset + buffer.len() as u64; let old_size = ic_cdk::api::stable::stable64_size() * WASM_PAGE_SIZE; @@ -653,21 +288,13 @@ fn stable_write(offset: u64, buffer: Vec) { ic_cdk::api::stable::stable64_write(offset, buffer.as_slice()); } -#[ic_cdk_macros::update(guard = "is_authorized_admin")] +#[update(guard = "require_admin_or_controller")] #[candid_method] fn authorize(principal: Principal, auth: Auth) { - AUTH.with(|a| { - let mut auth_map = a.borrow_mut(); - let principal = PrincipalStorable(principal); - if let Some(v) = auth_map.get(&principal) { - auth_map.insert(principal, v | (auth as u32)); - } else { - auth_map.insert(principal, auth as u32); - } - }); + do_authorize(principal, auth) } -#[ic_cdk_macros::query(guard = "is_authorized_admin")] +#[query(guard = "require_admin_or_controller")] #[candid_method(query)] fn get_authorized(auth: Auth) -> Vec { AUTH.with(|a| { @@ -681,53 +308,13 @@ fn get_authorized(auth: Auth) -> Vec { }) } -#[ic_cdk_macros::update(guard = "is_authorized_admin")] +#[update(guard = "require_admin_or_controller")] #[candid_method] fn deauthorize(principal: Principal, auth: Auth) { - AUTH.with(|a| { - let mut auth_map = a.borrow_mut(); - let principal = PrincipalStorable(principal); - if let Some(v) = auth_map.get(&principal) { - auth_map.insert(principal, v & !(auth as u32)); - } - }); -} - -fn is_authorized_admin() -> Result<(), String> { - if is_authorized(Auth::Admin) { - Ok(()) - } else { - Err("You are not authorized".to_string()) - } -} - -fn is_authorized_register_provider() -> Result<(), String> { - if is_authorized(Auth::RegisterProvider) { - Ok(()) - } else { - Err("You are not authorized".to_string()) - } -} - -fn is_authorized(auth: Auth) -> bool { - ic_cdk::api::is_controller(&ic_cdk::caller()) - || is_authorized_principal(&ic_cdk::caller(), auth) -} - -fn is_authorized_principal(principal: &Principal, auth: Auth) -> bool { - if auth == Auth::Rpc && METADATA.with(|m| m.borrow().get().open_rpc_access) { - return true; - } - AUTH.with(|a| { - if let Some(v) = a.borrow().get(&PrincipalStorable(*principal)) { - (v & (auth as u32)) != 0 - } else { - false - } - }) + do_deauthorize(principal, auth) } -#[ic_cdk_macros::update(guard = "is_authorized_admin")] +#[update(guard = "require_admin_or_controller")] #[candid_method] fn set_open_rpc_access(open_rpc_access: bool) { METADATA.with(|m| { @@ -737,55 +324,44 @@ fn set_open_rpc_access(open_rpc_access: bool) { }); } -#[ic_cdk_macros::query(guard = "is_authorized_admin")] +#[query(guard = "require_admin_or_controller")] #[candid_method(query)] fn get_open_rpc_access() -> bool { METADATA.with(|m| m.borrow().get().open_rpc_access) } -fn encode_metrics(w: &mut ic_metrics_encoder::MetricsEncoder>) -> std::io::Result<()> { - w.encode_gauge( - "canister_version", - ic_cdk::api::canister_version() as f64, - "Canister version.", - )?; - w.encode_gauge( - "stable_memory_pages", - ic_cdk::api::stable::stable64_size() as f64, - "Size of the stable memory allocated by this canister measured in 64K Wasm pages.", - )?; - w.encode_counter( - "json_rpc_requests", - get_metric!(json_rpc_requests) as f64, - "Number of json_rpc_request() calls.", - )?; - w.encode_counter( - "json_rpc_request_cycles_charged", - get_metric!(json_rpc_request_cycles_charged) as f64, - "Cycles charged by json_rpc_request() calls.", - )?; - w.encode_counter( - "json_rpc_request_cycles_refunded", - get_metric!(json_rpc_request_cycles_refunded) as f64, - "Cycles refunded by json_rpc_request() calls.", - )?; - METRICS.with(|m| { - m.borrow() - .json_rpc_host_requests - .iter() - .map(|(k, v)| { - w.counter_vec( - "json_rpc_host_requests", - "Number of json_rpc_request() calls to a service host.", - ) - .and_then(|m| m.value(&[("host", k)], *v as f64)) - .and(Ok(())) - }) - .find(|e| e.is_err()) - .unwrap_or(Ok(())) - })?; +#[update(guard = "require_admin_or_controller")] +#[candid_method] +fn set_nodes_in_subnet(nodes_in_subnet: u32) { + METADATA.with(|m| { + let mut metadata = m.borrow().get().clone(); + metadata.nodes_in_subnet = nodes_in_subnet; + m.borrow_mut().set(metadata).unwrap(); + }); +} + +#[query(guard = "require_admin_or_controller")] +#[candid_method(query)] +fn get_nodes_in_subnet() -> u32 { + METADATA.with(|m| m.borrow().get().nodes_in_subnet) +} - Ok(()) +fn initialize() { + SERVICE_HOSTS_ALLOWLIST + .with(|a| (*a.borrow_mut()) = AllowlistSet::from_iter(INITIAL_SERVICE_HOSTS_ALLOWLIST)); + + for principal in RPC_ALLOWLIST.iter() { + authorize(to_principal(principal), Auth::Rpc); + } + for principal in REGISTER_PROVIDER_ALLOWLIST.iter() { + authorize(to_principal(principal), Auth::RegisterProvider); + } + for principal in FREE_RPC_ALLOWLIST.iter() { + authorize(to_principal(principal), Auth::FreeRpc); + } + for principal in AUTHORIZED_ADMIN.iter() { + authorize(to_principal(principal), Auth::Admin); + } } #[cfg(not(any(target_arch = "wasm32", test)))] @@ -798,7 +374,7 @@ fn main() { fn main() {} #[test] -fn check_candid_interface() { +fn test_candid_interface() { use candid::utils::{service_compatible, CandidSource}; use std::path::Path; @@ -807,80 +383,7 @@ fn check_candid_interface() { service_compatible( CandidSource::Text(&new_interface), - CandidSource::File(Path::new("iceth.did")), - ) - .unwrap(); -} - -#[test] -fn check_json_rpc_cycles_cost() { - METADATA.with(|m| { - let mut metadata = m.borrow().get().clone(); - metadata.nodes_in_subnet = 13; - m.borrow_mut().set(metadata).unwrap(); - }); - - let base_cost = json_rpc_cycles_cost( - "{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}", - "https://cloudflare-eth.com", - 1000, - ); - let s10 = "0123456789"; - let base_cost_s10 = json_rpc_cycles_cost( - &("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}".to_string() - + s10), - "https://cloudflare-eth.com", - 1000, - ); - assert_eq!( - base_cost + 10 * (INGRESS_MESSAGE_BYTE_RECEIVED_COST + HTTP_OUTCALL_BYTE_RECEIEVED_COST), - base_cost_s10 - ) -} - -#[test] -fn check_json_rpc_provider_cycles_cost() { - METADATA.with(|m| { - let mut metadata = m.borrow().get().clone(); - metadata.nodes_in_subnet = 13; - m.borrow_mut().set(metadata).unwrap(); - }); - - let base_cost = json_rpc_provider_cycles_cost( - "{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}", - 0, - 2, - ); - let s10 = "0123456789"; - let base_cost_s10 = json_rpc_provider_cycles_cost( - &("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}".to_string() - + s10), - 1000, - 2, - ); - assert_eq!(base_cost + (10 * 2 + 1000) * 13, base_cost_s10) -} - -#[test] -fn check_authorization() { - let principal1 = Principal::from_text( - "k5dlc-ijshq-lsyre-qvvpq-2bnxr-pb26c-ag3sc-t6zo5-rdavy-recje-zqe".to_string(), + CandidSource::File(Path::new("candid/ic_eth.did")), ) .unwrap(); - let principal2 = - Principal::from_text("yxhtl-jlpgx-wqnzc-ysego-h6yqe-3zwfo-o3grn-gvuhm-nz3kv-ainub-6ae") - .unwrap(); - assert!(!is_authorized_principal(&principal1, Auth::Rpc)); - assert!(!is_authorized_principal(&principal2, Auth::Rpc)); - authorize(principal1, Auth::Rpc); - assert!(is_authorized_principal(&principal1, Auth::Rpc)); - assert!(!is_authorized_principal(&principal2, Auth::Rpc)); - deauthorize(principal1, Auth::Rpc); - assert!(!is_authorized_principal(&principal1, Auth::Rpc)); - assert!(!is_authorized_principal(&principal2, Auth::Rpc)); - - // Test that a principal with the RegisterProvider permission does not have Admin permissions. - authorize(principal1, Auth::RegisterProvider); - assert!(!is_authorized_principal(&principal1, Auth::Admin)); - assert!(is_authorized_principal(&principal1, Auth::RegisterProvider)); } diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 00000000..664fefe5 --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,43 @@ +use candid::Principal; +use ic_canister_log::declare_log_buffer; + +#[cfg(not(target_arch = "wasm32"))] +use ic_stable_structures::file_mem::FileMemory; +use ic_stable_structures::memory_manager::{MemoryId, MemoryManager, VirtualMemory}; +#[cfg(target_arch = "wasm32")] +use ic_stable_structures::DefaultMemoryImpl; +use ic_stable_structures::{Cell, StableBTreeMap}; +use std::cell::RefCell; +use std::collections::hash_set::HashSet; + +use crate::types::*; + +#[cfg(not(target_arch = "wasm32"))] +type Memory = VirtualMemory; +#[cfg(target_arch = "wasm32")] +type Memory = VirtualMemory; + +declare_log_buffer!(name = INFO, capacity = 1000); +declare_log_buffer!(name = ERROR, capacity = 1000); + +thread_local! { + // Transient static data: this is reset when the canister is upgraded. + pub static METRICS: RefCell = RefCell::new(Metrics::default()); + pub static SERVICE_HOSTS_ALLOWLIST: RefCell = RefCell::new(AllowlistSet::new()); + pub static AUTH_STABLE: RefCell> = RefCell::new(HashSet::::new()); + + // Stable static data: this is preserved when the canister is upgraded. + #[cfg(not(target_arch = "wasm32"))] + pub static MEMORY_MANAGER: RefCell> = + RefCell::new(MemoryManager::init(FileMemory::new(std::fs::OpenOptions::new().read(true).write(true).create(true).open("target/test_stable_memory.bin").unwrap()))); + #[cfg(target_arch = "wasm32")] + pub static MEMORY_MANAGER: RefCell> = + RefCell::new(MemoryManager::init(DefaultMemoryImpl::default())); + pub static METADATA: RefCell> = RefCell::new(Cell::init( + MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(0))), + ::default()).unwrap()); + pub static AUTH: RefCell> = RefCell::new( + StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(1))))); + pub static PROVIDERS: RefCell> = RefCell::new( + StableBTreeMap::init(MEMORY_MANAGER.with(|m| m.borrow().get(MemoryId::new(2))))); +} diff --git a/src/metrics.rs b/src/metrics.rs new file mode 100644 index 00000000..7cfebd25 --- /dev/null +++ b/src/metrics.rs @@ -0,0 +1,77 @@ +#[macro_export] +macro_rules! inc_metric { + ($metric:ident) => {{ + $crate::METRICS.with(|m| m.borrow_mut().$metric += 1); + }}; +} + +#[macro_export] +macro_rules! inc_metric_entry { + ($metric:ident, $entry:expr) => {{ + $crate::METRICS.with(|m| { + m.borrow_mut() + .$metric + .entry($entry.clone()) + .and_modify(|counter| *counter += 1) + .or_insert(1); + }); + }}; +} + +#[macro_export] +macro_rules! add_metric { + ($metric:ident, $value:expr) => {{ + $crate::METRICS.with(|m| m.borrow_mut().$metric += $value); + }}; +} + +#[macro_export] +macro_rules! get_metric { + ($metric:ident) => {{ + $crate::METRICS.with(|m| m.borrow().$metric) + }}; +} + +pub fn encode_metrics(w: &mut ic_metrics_encoder::MetricsEncoder>) -> std::io::Result<()> { + w.encode_gauge( + "canister_version", + ic_cdk::api::canister_version() as f64, + "Canister version.", + )?; + w.encode_gauge( + "stable_memory_pages", + ic_cdk::api::stable::stable64_size() as f64, + "Size of the stable memory allocated by this canister measured in 64K Wasm pages.", + )?; + w.encode_counter( + "requests", + get_metric!(requests) as f64, + "Number of request() calls.", + )?; + w.encode_counter( + "request_cycles_charged", + get_metric!(request_cycles_charged) as f64, + "Cycles charged by request() calls.", + )?; + w.encode_counter( + "request_cycles_refunded", + get_metric!(request_cycles_refunded) as f64, + "Cycles refunded by request() calls.", + )?; + crate::METRICS.with(|m| { + m.borrow() + .host_requests + .iter() + .map(|(k, v)| { + w.counter_vec( + "json_rpc_host_requests", + "Number of request() calls to a service host.", + ) + .and_then(|m| m.value(&[("host", k)], *v as f64)) + .and(Ok(())) + }) + .find(|e| e.is_err()) + .unwrap_or(Ok(())) + })?; + Ok(()) +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 00000000..13a903b0 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,149 @@ +use candid::{CandidType, Decode, Deserialize, Encode, Principal}; +use ic_stable_structures::{BoundedStorable, Storable}; +use num_derive::FromPrimitive; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +use crate::constants::STRING_STORABLE_MAX_SIZE; + +pub enum ResolvedSource { + Url(String), + Provider(Provider), +} + +#[derive(Default)] +pub struct Metrics { + pub requests: u64, + pub request_cycles_charged: u128, + pub request_cycles_refunded: u128, + pub request_err_no_permission: u64, + pub request_err_host_not_allowed: u64, + pub request_err_http: u64, + pub host_requests: HashMap, +} + +// These need to be powers of two so that they can be used as bit fields. +#[derive(Clone, Debug, PartialEq, CandidType, FromPrimitive, Deserialize)] +pub enum Auth { + Admin = 0b0001, + Rpc = 0b0010, + RegisterProvider = 0b0100, + FreeRpc = 0b1000, +} + +#[derive(Clone, Debug, Default, CandidType, Deserialize)] +pub struct Metadata { + pub nodes_in_subnet: u32, + pub next_provider_id: u64, + pub open_rpc_access: bool, +} + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct StringStorable(pub String); + +#[derive(PartialEq, Eq, PartialOrd, Ord, Clone)] +pub struct PrincipalStorable(pub Principal); + +impl Storable for StringStorable { + fn to_bytes(&self) -> std::borrow::Cow<[u8]> { + // String already implements `Storable`. + self.0.to_bytes() + } + + fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self { + Self(String::from_bytes(bytes)) + } +} + +impl BoundedStorable for StringStorable { + const MAX_SIZE: u32 = STRING_STORABLE_MAX_SIZE; + const IS_FIXED_SIZE: bool = false; +} + +impl Storable for PrincipalStorable { + fn to_bytes(&self) -> std::borrow::Cow<[u8]> { + std::borrow::Cow::from(self.0.as_slice()) + } + + fn from_bytes(bytes: std::borrow::Cow<[u8]>) -> Self { + Self(Principal::from_slice(&bytes)) + } +} + +impl BoundedStorable for PrincipalStorable { + const MAX_SIZE: u32 = 29; + const IS_FIXED_SIZE: bool = false; +} + +#[derive(Debug, CandidType)] +pub struct RegisteredProvider { + pub provider_id: u64, + pub owner: Principal, + pub chain_id: u64, + pub base_url: String, + pub cycles_per_call: u64, + pub cycles_per_message_byte: u64, +} + +#[derive(Debug, CandidType, Deserialize)] +pub struct RegisterProvider { + pub chain_id: u64, + pub base_url: String, + pub credential_path: String, + pub cycles_per_call: u64, + pub cycles_per_message_byte: u64, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub struct Provider { + pub provider_id: u64, + pub owner: Principal, + pub chain_id: u64, + pub base_url: String, + pub credential_path: String, + pub cycles_per_call: u64, + pub cycles_per_message_byte: u64, + pub cycles_owed: u128, +} + +impl Provider { + pub fn service_url(&self) -> String { + format!("{}{}", self.base_url, self.credential_path) + } +} + +impl Storable for Metadata { + fn to_bytes(&self) -> std::borrow::Cow<[u8]> { + Cow::Owned(Encode!(self).unwrap()) + } + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Decode!(&bytes, Self).unwrap() + } +} + +impl Storable for Provider { + fn to_bytes(&self) -> std::borrow::Cow<[u8]> { + Cow::Owned(Encode!(self).unwrap()) + } + fn from_bytes(bytes: Cow<[u8]>) -> Self { + Decode!(&bytes, Self).unwrap() + } +} + +impl BoundedStorable for Provider { + const MAX_SIZE: u32 = 256; // A reasonable limit. + const IS_FIXED_SIZE: bool = false; +} + +#[derive(CandidType, Debug)] +pub enum EthRpcError { + NoPermission, + TooFewCycles(String), + ServiceUrlParseError, + ServiceUrlHostMissing, + ServiceUrlHostNotAllowed, + ProviderNotFound, + HttpRequestError { code: u32, message: String }, +} + +pub type AllowlistSet = HashSet<&'static &'static str>; diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 00000000..776e11cd --- /dev/null +++ b/src/util.rs @@ -0,0 +1,8 @@ +use candid::Principal; + +pub fn to_principal(principal: &str) -> Principal { + match Principal::from_text(principal) { + Ok(p) => p, + Err(e) => ic_cdk::trap(&format!("failed to convert Principal {principal} {e:?}",)), + } +} diff --git a/src/validate.rs b/src/validate.rs new file mode 100644 index 00000000..d2bf3d35 --- /dev/null +++ b/src/validate.rs @@ -0,0 +1,13 @@ +use crate::*; + +pub fn validate_base_url(base_url: &str) { + if SERVICE_HOSTS_ALLOWLIST.with(|a| !a.borrow().contains(&base_url)) { + ic_cdk::trap("base_url host not allowed"); + } +} + +pub fn validate_credential_path(credential_path: &str) { + if !(credential_path.starts_with('/') || credential_path.starts_with('?')) { + ic_cdk::trap("secret path must start with '/' or '?'"); + } +}