Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Simplify calling a specific EVM network #38

Merged
merged 12 commits into from
Sep 18, 2023
32 changes: 7 additions & 25 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,46 +26,28 @@ Confirm the authenticity of a message signed by an Ethereum private key. Check o

Make a request to a Web2 Ethereum node using the caller's URL to an openly available JSON-RPC service, or the caller's URL (including an API key if necessary). No registered API key of the canister is used in this scenario.

request: (service_url: text, json_rpc_payload: text, max_response_bytes: nat64) -> (EthRpcResult);
request: (source: Source, json_rpc_payload: text, max_response_bytes: nat64) -> (EthRpcResult);

* `service_url`: The URL of the service, including any API key if required for access-protected services.
* `source`: Any of the following:
* `#Url : text` The URL of the service, including any API key if required for access-protected services.
* `#Chain : nat64` The relevant EVM network identifier ([reference list](https://chainlist.org/?testnets=true)).
* `#Provider : nat64` The ID of the provider to be used for this call. Call `get_providers` to view a full list of providers.
* `json_rpc_payload`: The payload for the JSON-RPC request. View examples in the [Ethereum documentation](https://ethereum.org/en/developers/docs/apis/json-rpc/).
* `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.


### `provider_request`

Make a request to a Web2 Ethereum node using a registered provider for a JSON-RPC service. There is no need for the client to have any established relationship with the API service.

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;
request_cost: (source: Source, 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.
* `source`: See `request`.
* `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;

* `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_cost`.
* `max_response_bytes`: See `request_cost`.

### `get_providers`

Returns a list of currently registered `RegisteredProvider` entries of the canister.
Expand Down
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,26 +47,27 @@ dfx start --background
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
dfx canister call ic_eth request '(variant {Url="https://cloudflare-eth.com/v1/mainnet"}, "{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}", 1000)' --wallet $(dfx identity get-wallet) --with-cycles 600000000
```

## Examples

### Ethereum RPC (IC mainnet)
```bash
dfx canister call ic_eth --network ic --wallet $(dfx identity --network ic get-wallet) --with-cycles 600000000 request '(variant {Chain=0x1},"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)'
```

### Ethereum RPC (local replica)
```bash
# 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)'
dfx canister call ic_eth --wallet $(dfx identity get-wallet) --with-cycles 600000000 request '(variant {Url="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 '(variant {Url="https://ethereum.publicnode.com"},"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)'

# 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)'
```

### Ethereum RPC (IC mainnet)
```bash
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)'
# Use a specific EVM chain
dfx canister call ic_eth --wallet $(dfx identity get-wallet) --with-cycles 600000000 request '(variant {Chain=0x1},"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",1000)'
```

### Authorization (local replica)
Expand Down
32 changes: 21 additions & 11 deletions candid/ic_eth.did
Original file line number Diff line number Diff line change
Expand Up @@ -7,40 +7,50 @@ type EthRpcError = variant {
ServiceUrlHostMissing;
ProviderNotFound;
NoPermission;
ProviderNotActive;
};
type RegisterProvider = record {
type ProviderView = record {
base_url : text;
active : bool;
owner : principal;
provider_id : nat64;
cycles_per_message_byte : nat64;
chain_id : nat64;
cycles_per_call : nat64;
credential_path : text;
};
type RegisteredProvider = record {
type RegisterProvider = record {
base_url : text;
owner : principal;
provider_id : nat64;
cycles_per_message_byte : nat64;
chain_id : nat64;
cycles_per_call : nat64;
credential_path : text;
};
type Result = variant { Ok : vec nat8; Err : EthRpcError };
type Result_1 = variant { Ok : nat; Err : EthRpcError };
type Source = variant { Url : text; Chain : nat64; Provider : nat64 };
type UpdateProvider = record {
base_url : opt text;
active : opt bool;
provider_id : nat64;
cycles_per_message_byte : opt nat64;
cycles_per_call : opt nat64;
credential_path : opt text;
};
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;
get_providers : () -> (vec ProviderView) query;
register_provider : (RegisterProvider) -> (nat64);
request : (text, text, nat64) -> (Result);
request_cost : (text, text, nat64) -> (nat) query;
request : (Source, text, nat64) -> (Result);
request_cost : (Source, text, nat64) -> (Result_1) query;
set_nodes_in_subnet : (nat32) -> ();
set_open_rpc_access : (bool) -> ();
unregister_provider : (nat64) -> ();
update_provider_credential : (nat64, text) -> ();
update_provider : (UpdateProvider) -> ();
verify_signature : (vec nat8, vec nat8, vec nat8) -> (bool) query;
withdraw_owed_cycles : (nat64, principal) -> ();
}
8 changes: 4 additions & 4 deletions scripts/local
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ dfx canister call ic_eth authorize "(principal \"$PRINCIPAL\", variant { Registe

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 request_cost '(variant {Chain=1}, "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)'
dfx canister call ic_eth request '(variant {Chain=1}, "{ \"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)'
dfx canister call ic_eth request_cost '(variant {Provider=0}, "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)'
dfx canister call ic_eth request '(variant {Provider=0}, "{ \"jsonrpc\": \"2.0\", \"method\": \"eth_getBlockByNumber\", \"params\": [\"0x2244\", true], \"id\": 1 }", 1000)'
41 changes: 35 additions & 6 deletions src/accounting.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
use crate::*;

/// Calculate the baseline cost of sending a JSON-RPC request using HTTP outcalls.
pub fn get_request_cost(
source: &ResolvedSource,
json_rpc_payload: &str,
max_response_bytes: u64,
) -> u128 {
let (http_cost, provider_cost) =
get_request_costs(source, json_rpc_payload, max_response_bytes);
http_cost + provider_cost
}

pub fn get_request_costs(
source: &ResolvedSource,
json_rpc_payload: &str,
max_response_bytes: u64,
) -> (u128, u128) {
match source {
ResolvedSource::Url(s) => (
get_http_request_cost(s, json_rpc_payload, max_response_bytes),
0,
),
ResolvedSource::Provider(p) => (
get_http_request_cost(&p.service_url(), json_rpc_payload, max_response_bytes),
get_provider_cost(p, json_rpc_payload),
),
}
}

/// Calculate the baseline cost of sending a JSON-RPC request using HTTP outcalls.
pub fn get_http_request_cost(
service_url: &str,
json_rpc_payload: &str,
max_response_bytes: u64,
) -> u128 {
let nodes_in_subnet = METADATA.with(|m| m.borrow().get().nodes_in_subnet);
Expand All @@ -17,7 +44,7 @@ pub fn get_request_cost(
}

/// Calculate the additional cost for calling a registered JSON-RPC provider.
pub fn get_provider_cost(json_rpc_payload: &str, provider: &Provider) -> u128 {
pub fn get_provider_cost(provider: &Provider, json_rpc_payload: &str) -> 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;
Expand All @@ -33,15 +60,15 @@ fn test_request_cost() {
});

let base_cost = get_request_cost(
&ResolvedSource::Url("https://cloudflare-eth.com".to_string()),
"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",
"https://cloudflare-eth.com",
1000,
);
let s10 = "0123456789";
let base_cost_s10 = get_request_cost(
&ResolvedSource::Url("https://cloudflare-eth.com".to_string()),
&("{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}".to_string()
+ s10),
"https://cloudflare-eth.com",
1000,
);
assert_eq!(
Expand All @@ -67,10 +94,11 @@ fn test_provider_cost() {
cycles_owed: 0,
cycles_per_call: 0,
cycles_per_message_byte: 2,
active: true,
};
let base_cost = get_provider_cost(
"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",
&provider,
"{\"jsonrpc\":\"2.0\",\"method\":\"eth_gasPrice\",\"params\":[],\"id\":1}",
);

let provider_s10 = Provider {
Expand All @@ -82,12 +110,13 @@ fn test_provider_cost() {
cycles_owed: 0,
cycles_per_call: 1000,
cycles_per_message_byte: 2,
active: true,
};
let s10 = "0123456789";
let base_cost_s10 = get_provider_cost(
&provider_s10,
&("{\"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)
}
7 changes: 0 additions & 7 deletions src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,3 @@ pub const INITIAL_SERVICE_HOSTS_ALLOWLIST: &[&str] = &[
// Principals allowed to send JSON-RPC requests.
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-RPC requests.
pub const FREE_RPC_ALLOWLIST: &[&str] = &[];
// Principals who have Admin authorization.
pub const AUTHORIZED_ADMIN: &[&str] = &[];
8 changes: 2 additions & 6 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub async fn do_http_request(
return Err(EthRpcError::NoPermission);
}
let cycles_available = ic_cdk::api::call::msg_cycles_available128();
let cost = get_request_cost(&source, json_rpc_payload, max_response_bytes);
let (service_url, provider) = match source {
ResolvedSource::Url(url) => (url, None),
ResolvedSource::Provider(provider) => (provider.service_url(), Some(provider)),
Expand All @@ -31,11 +32,6 @@ pub async fn do_http_request(
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!(
Expand All @@ -44,7 +40,7 @@ pub async fn do_http_request(
}
ic_cdk::api::call::msg_cycles_accept128(cost);
if let Some(mut provider) = provider {
provider.cycles_owed += provider_cost;
provider.cycles_owed += get_provider_cost(&provider, json_rpc_payload);
PROVIDERS.with(|p| {
// Error should not happen here as it was checked before.
p.borrow_mut()
Expand Down
Loading
Loading