From c3f56364172e4f6c86978d9ad198051d3b768e8a Mon Sep 17 00:00:00 2001 From: Kristofer Date: Fri, 18 Oct 2024 10:18:17 +0200 Subject: [PATCH] Add a send_eth function that does manual nonce handling --- src/backend/backend.did | 1 + src/backend/src/service/mod.rs | 1 + src/backend/src/service/send_eth.rs | 95 +++++++++++++++++++ .../src/service/send_eth_with_fillers.rs | 11 +++ src/frontend/routeTree.gen.ts | 25 +++++ src/frontend/routes/index.lazy.tsx | 3 + src/frontend/routes/send_eth.lazy.tsx | 74 +++++++++++++++ 7 files changed, 210 insertions(+) create mode 100644 src/backend/src/service/send_eth.rs create mode 100644 src/frontend/routes/send_eth.lazy.tsx diff --git a/src/backend/backend.did b/src/backend/backend.did index 52d8067..062d174 100644 --- a/src/backend/backend.did +++ b/src/backend/backend.did @@ -8,6 +8,7 @@ service : { get_balance : (opt text) -> (Result); get_batch_balances : (vec text) -> (Result); get_latest_block : () -> (Result); + send_eth : () -> (Result); send_eth_with_fillers : () -> (Result); sign_message : (text) -> (Result); watch_contract_event : () -> (Result); diff --git a/src/backend/src/service/mod.rs b/src/backend/src/service/mod.rs index b0c0c83..e77b382 100644 --- a/src/backend/src/service/mod.rs +++ b/src/backend/src/service/mod.rs @@ -2,6 +2,7 @@ mod get_address; mod get_balance; mod get_batch_balances; mod get_latest_block; +mod send_eth; mod send_eth_with_fillers; mod sign_message; mod watch_blocks; diff --git a/src/backend/src/service/send_eth.rs b/src/backend/src/service/send_eth.rs new file mode 100644 index 0000000..fbfa0ce --- /dev/null +++ b/src/backend/src/service/send_eth.rs @@ -0,0 +1,95 @@ +use std::cell::RefCell; + +use alloy::{ + network::{EthereumWallet, TransactionBuilder}, + primitives::U256, + providers::{Provider, ProviderBuilder}, + rpc::types::request::TransactionRequest, + signers::Signer, + transports::icp::IcpConfig, +}; + +use crate::{create_icp_sepolia_signer, get_rpc_service_sepolia}; + +thread_local! { + static NONCE: RefCell> = const { RefCell::new(None) }; +} + +/// This function will attempt to send 100 wei to the ethereum address of the canister. +/// +/// Transfer some SepoliaEth to the canister address before calling this function. +/// +/// Nonce handling is implemented manually instead of relying on the Alloy built in +/// `with_recommended_fillers` method. This minimizes the number of requests sent to the +/// EVM RPC. +/// +/// The following RPC calls are made to complete a transaction: +/// - `eth_getTransactionCount`: To determine the next nonce. This call is only made once after +/// canister deployment, then the nonces are cached. +/// - `eth_estimateGas`: To determine the gas limit +/// - `eth_sendRawTransaction`: The transaction +/// - `eth_getTransactionByHash`: To determine if transaction was successful. Increment nonce only +/// if transaction was successful. +/// +/// Even though this function makes half as many RPC calls as `send_eth_with_fillers` it is still +/// recommended to use a deduplication proxy between the EVM RPC canister and the RPC provider +/// (Alchemy, etc). For a fully decentralised deployment, one option is also to deploy a copy of +/// the EVM RPC canister yourself on an app subnet with only 13 nodes and your own RPC API key. +/// Perhaps 3 calls * 13 = 39 fits within the RPC call limits. +#[ic_cdk::update] +async fn send_eth() -> Result { + // Setup signer + let signer = create_icp_sepolia_signer().await; + let address = signer.address(); + + // Setup provider + let wallet = EthereumWallet::from(signer); + let rpc_service = get_rpc_service_sepolia(); + let config = IcpConfig::new(rpc_service); + let provider = ProviderBuilder::new() + .with_gas_estimation() + .wallet(wallet) + .on_icp(config); + + // Attempt to get nonce from thread-local storage + let maybe_nonce = NONCE.with_borrow(|maybe_nonce| { + // If a nonce exists, the next nonce to use is latest nonce + 1 + maybe_nonce.map(|nonce| nonce + 1) + }); + + // If no nonce exists, get it from the provider + let nonce = if let Some(nonce) = maybe_nonce { + nonce + } else { + provider.get_transaction_count(address).await.unwrap_or(0) + }; + + let tx = TransactionRequest::default() + .with_to(address) + .with_value(U256::from(100)) + .with_nonce(nonce) + .with_gas_limit(21_000) + .with_chain_id(11155111); + + let transport_result = provider.send_transaction(tx.clone()).await; + match transport_result { + Ok(builder) => { + let node_hash = *builder.tx_hash(); + let tx_response = provider.get_transaction_by_hash(node_hash).await.unwrap(); + + match tx_response { + Some(tx) => { + // The transaction has been mined and included in a block, the nonce + // has been consumed. Save it to thread-local storage. Next transaction + // for this address will use a nonce that is = this nonce + 1 + NONCE.with_borrow_mut(|nonce| { + *nonce = Some(tx.nonce); + }); + Ok(format!("{:?}", tx)) + } + None => Err("Could not get transaction.".to_string()), + } + } + Err(e) => Err(format!("{:?}", e)), + } +} diff --git a/src/backend/src/service/send_eth_with_fillers.rs b/src/backend/src/service/send_eth_with_fillers.rs index 4e0b449..6f7ca8c 100644 --- a/src/backend/src/service/send_eth_with_fillers.rs +++ b/src/backend/src/service/send_eth_with_fillers.rs @@ -19,6 +19,17 @@ use crate::{create_icp_sepolia_signer, get_rpc_service_sepolia}; /// - `eth_chainId`: To determine the chain id /// - `eth_feeHistory`: To determine historic gas price /// - `eth_estimateGas`: To determine the gas limit +/// - `eth_sendRawTransaction`: The transaction +/// - `eth_getTransactionByHash`: To determine if transaction was successful. Increment nonce only +/// if transaction was successful. +/// +/// Using `with_recommended_fillers` is only recommended if you use a deduplication proxy between +/// the EVM RPC canister and the RPC service (Alchemy, etc). When making an EVM RPC call on IC, +/// that call is executed by all the nodes in the subnet, currently 34 on the subnet where the +/// EVM RPC canister resides. Usinf this example without a deduplication proxy would result in +/// 6 x 34 = 204 calls being made during the span of a few seconds. That most likely leads to the +/// ceiling of number of requests per second being hit and the RPC provider responding with an +/// error. #[ic_cdk::update] async fn send_eth_with_fillers() -> Result { let rpc_service = get_rpc_service_sepolia(); diff --git a/src/frontend/routeTree.gen.ts b/src/frontend/routeTree.gen.ts index 44ad39a..edd604c 100644 --- a/src/frontend/routeTree.gen.ts +++ b/src/frontend/routeTree.gen.ts @@ -20,6 +20,7 @@ const WatchusdctransferLazyImport = createFileRoute('/watch_usdc_transfer')() const WatchblocksLazyImport = createFileRoute('/watch_blocks')() const SignmessageLazyImport = createFileRoute('/sign_message')() const SendethwithfillersLazyImport = createFileRoute('/send_eth_with_fillers')() +const SendethLazyImport = createFileRoute('/send_eth')() const GetlatestblockLazyImport = createFileRoute('/get_latest_block')() const GetbatchbalancesLazyImport = createFileRoute('/get_batch_balances')() const GetbalanceLazyImport = createFileRoute('/get_balance')() @@ -52,6 +53,11 @@ const SendethwithfillersLazyRoute = SendethwithfillersLazyImport.update({ import('./routes/send_eth_with_fillers.lazy').then((d) => d.Route), ) +const SendethLazyRoute = SendethLazyImport.update({ + path: '/send_eth', + getParentRoute: () => rootRoute, +} as any).lazy(() => import('./routes/send_eth.lazy').then((d) => d.Route)) + const GetlatestblockLazyRoute = GetlatestblockLazyImport.update({ path: '/get_latest_block', getParentRoute: () => rootRoute, @@ -120,6 +126,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GetlatestblockLazyImport parentRoute: typeof rootRoute } + '/send_eth': { + id: '/send_eth' + path: '/send_eth' + fullPath: '/send_eth' + preLoaderRoute: typeof SendethLazyImport + parentRoute: typeof rootRoute + } '/send_eth_with_fillers': { id: '/send_eth_with_fillers' path: '/send_eth_with_fillers' @@ -159,6 +172,7 @@ export interface FileRoutesByFullPath { '/get_balance': typeof GetbalanceLazyRoute '/get_batch_balances': typeof GetbatchbalancesLazyRoute '/get_latest_block': typeof GetlatestblockLazyRoute + '/send_eth': typeof SendethLazyRoute '/send_eth_with_fillers': typeof SendethwithfillersLazyRoute '/sign_message': typeof SignmessageLazyRoute '/watch_blocks': typeof WatchblocksLazyRoute @@ -171,6 +185,7 @@ export interface FileRoutesByTo { '/get_balance': typeof GetbalanceLazyRoute '/get_batch_balances': typeof GetbatchbalancesLazyRoute '/get_latest_block': typeof GetlatestblockLazyRoute + '/send_eth': typeof SendethLazyRoute '/send_eth_with_fillers': typeof SendethwithfillersLazyRoute '/sign_message': typeof SignmessageLazyRoute '/watch_blocks': typeof WatchblocksLazyRoute @@ -184,6 +199,7 @@ export interface FileRoutesById { '/get_balance': typeof GetbalanceLazyRoute '/get_batch_balances': typeof GetbatchbalancesLazyRoute '/get_latest_block': typeof GetlatestblockLazyRoute + '/send_eth': typeof SendethLazyRoute '/send_eth_with_fillers': typeof SendethwithfillersLazyRoute '/sign_message': typeof SignmessageLazyRoute '/watch_blocks': typeof WatchblocksLazyRoute @@ -198,6 +214,7 @@ export interface FileRouteTypes { | '/get_balance' | '/get_batch_balances' | '/get_latest_block' + | '/send_eth' | '/send_eth_with_fillers' | '/sign_message' | '/watch_blocks' @@ -209,6 +226,7 @@ export interface FileRouteTypes { | '/get_balance' | '/get_batch_balances' | '/get_latest_block' + | '/send_eth' | '/send_eth_with_fillers' | '/sign_message' | '/watch_blocks' @@ -220,6 +238,7 @@ export interface FileRouteTypes { | '/get_balance' | '/get_batch_balances' | '/get_latest_block' + | '/send_eth' | '/send_eth_with_fillers' | '/sign_message' | '/watch_blocks' @@ -233,6 +252,7 @@ export interface RootRouteChildren { GetbalanceLazyRoute: typeof GetbalanceLazyRoute GetbatchbalancesLazyRoute: typeof GetbatchbalancesLazyRoute GetlatestblockLazyRoute: typeof GetlatestblockLazyRoute + SendethLazyRoute: typeof SendethLazyRoute SendethwithfillersLazyRoute: typeof SendethwithfillersLazyRoute SignmessageLazyRoute: typeof SignmessageLazyRoute WatchblocksLazyRoute: typeof WatchblocksLazyRoute @@ -245,6 +265,7 @@ const rootRouteChildren: RootRouteChildren = { GetbalanceLazyRoute: GetbalanceLazyRoute, GetbatchbalancesLazyRoute: GetbatchbalancesLazyRoute, GetlatestblockLazyRoute: GetlatestblockLazyRoute, + SendethLazyRoute: SendethLazyRoute, SendethwithfillersLazyRoute: SendethwithfillersLazyRoute, SignmessageLazyRoute: SignmessageLazyRoute, WatchblocksLazyRoute: WatchblocksLazyRoute, @@ -268,6 +289,7 @@ export const routeTree = rootRoute "/get_balance", "/get_batch_balances", "/get_latest_block", + "/send_eth", "/send_eth_with_fillers", "/sign_message", "/watch_blocks", @@ -289,6 +311,9 @@ export const routeTree = rootRoute "/get_latest_block": { "filePath": "get_latest_block.lazy.tsx" }, + "/send_eth": { + "filePath": "send_eth.lazy.tsx" + }, "/send_eth_with_fillers": { "filePath": "send_eth_with_fillers.lazy.tsx" }, diff --git a/src/frontend/routes/index.lazy.tsx b/src/frontend/routes/index.lazy.tsx index 4876b00..ce0042b 100644 --- a/src/frontend/routes/index.lazy.tsx +++ b/src/frontend/routes/index.lazy.tsx @@ -22,6 +22,9 @@ function Index() { + + + diff --git a/src/frontend/routes/send_eth.lazy.tsx b/src/frontend/routes/send_eth.lazy.tsx new file mode 100644 index 0000000..f9bfdac --- /dev/null +++ b/src/frontend/routes/send_eth.lazy.tsx @@ -0,0 +1,74 @@ +import { Link, createLazyFileRoute } from '@tanstack/react-router' + +import { backend } from '../../backend/declarations' +import { useMutation, useQuery } from '@tanstack/react-query' +import Source from '../components/source' +import Spinner from '../components/spinner' + +export const Route = createLazyFileRoute('/send_eth')({ + component: Page, +}) + +function Page() { + const { data: accountBalanceResult, isFetching: isFetchingAccountBalance } = + useQuery({ + queryKey: ['accountBalance'], + queryFn: () => backend.get_balance([]), + }) + + const accountBalance = + accountBalanceResult && 'Ok' in accountBalanceResult + ? accountBalanceResult.Ok + : undefined + + const { + data: txResult, + isPending: isSendingTx, + mutate: sendTx, + } = useMutation({ + mutationFn: () => backend.send_eth(), + }) + + return ( + <> + + + +
+

+ Send 100 wei from the canister eth address to, for the purposes of + this demo, back to the canister eth address. +

+

+ + If call fails due to lack of funds, top up the canister eth address + with some SepoliaEth. + +

+

+ + Instead of using Alloy fillers for nonce handling, the send_eth function + implements that manually instead to minimize the number of requests + sent to the RPC. + +

+

+ + This canister call can take up to a minute to complete, please be + patient. + +

+ +

+ Canister ETH balance:{' '} + {isFetchingAccountBalance ? : {accountBalance} wei} +

+ + {txResult &&
{JSON.stringify(txResult, null, 2)}
} + +
+ + ) +}