Skip to content

Commit

Permalink
Add a send_eth function that does manual nonce handling
Browse files Browse the repository at this point in the history
  • Loading branch information
kristoferlund committed Oct 18, 2024
1 parent 4fb1d60 commit c3f5636
Show file tree
Hide file tree
Showing 7 changed files with 210 additions and 0 deletions.
1 change: 1 addition & 0 deletions src/backend/backend.did
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/backend/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
95 changes: 95 additions & 0 deletions src/backend/src/service/send_eth.rs
Original file line number Diff line number Diff line change
@@ -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<Option<u64>> = 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<String, String> {
// 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)),
}
}
11 changes: 11 additions & 0 deletions src/backend/src/service/send_eth_with_fillers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
let rpc_service = get_rpc_service_sepolia();
Expand Down
25 changes: 25 additions & 0 deletions src/frontend/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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'
Expand All @@ -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
Expand All @@ -245,6 +265,7 @@ const rootRouteChildren: RootRouteChildren = {
GetbalanceLazyRoute: GetbalanceLazyRoute,
GetbatchbalancesLazyRoute: GetbatchbalancesLazyRoute,
GetlatestblockLazyRoute: GetlatestblockLazyRoute,
SendethLazyRoute: SendethLazyRoute,
SendethwithfillersLazyRoute: SendethwithfillersLazyRoute,
SignmessageLazyRoute: SignmessageLazyRoute,
WatchblocksLazyRoute: WatchblocksLazyRoute,
Expand All @@ -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",
Expand All @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/routes/index.lazy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ function Index() {
<Link to="/sign_message">
<button>sign_message(message)</button>
</Link>
<Link to="/send_eth">
<button>send_eth()</button>
</Link>
<Link to="/send_eth_with_fillers">
<button>send_eth_with_fillers()</button>
</Link>
Expand Down
74 changes: 74 additions & 0 deletions src/frontend/routes/send_eth.lazy.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Link to="/">
<button> Menu</button>
</Link>
<div className="card">
<p>
Send 100 wei from the canister eth address to, for the purposes of
this demo, back to the canister eth address.
</p>
<p>
<i>
If call fails due to lack of funds, top up the canister eth address
with some SepoliaEth.
</i>
</p>
<p>
<i>
Instead of using Alloy fillers for nonce handling, the <code>send_eth</code> function
implements that manually instead to minimize the number of requests
sent to the RPC.
</i>
</p>
<p>
<i>
This canister call can take up to a minute to complete, please be
patient.
</i>
</p>

<p>
Canister ETH balance:{' '}
{isFetchingAccountBalance ? <Spinner /> : <b>{accountBalance} wei</b>}
</p>
<button disabled={isSendingTx} onClick={() => void sendTx()}>
{isSendingTx ? <Spinner /> : 'send_eth()'}
</button>
{txResult && <pre>{JSON.stringify(txResult, null, 2)}</pre>}
<Source file="send_eth.rs" />
</div>
</>
)
}

0 comments on commit c3f5636

Please sign in to comment.