Building a Cross-Chain ETH Payment and E-Commerce Platform on the Internet Computer: A Step-by-Step Tutorial
This comprehensive tutorial guides you through the process of building a decentralized e-commerce platform on the Internet Computer that can accept Ethereum (ETH) payments, handle withdrawals, and manage a digital storefront. Starting from a basic template, we'll incrementally add features to create a robust, cross-chain solution.
This tutorial is a hands-on guide designed for developers new to the Internet Computer blockchain. I've created a set of libraries to make development easier and more enjoyable, and this tutorial walks you through the process of building a decentralized ETH payment system using these libraries.
We'll explore key features like HTTP outcalls and stable memory, providing a solid foundation for anyone looking to bring their dream project to life on the Internet Computer. Although this tutorial uses my specific set of methods and libraries, there are many other tools and techniques you could use.
For example, you could integrate the ic-eth-rpc
package for Ethereum RPC calls or accept ckETH as a payment method. The main goal here is to demonstrate the Internet Computer's flexibility and ease of use, especially when it comes to confirming transactions on-chain.
The goal of this tutorial is to create a fully functional decentralized e-commerce platform that:
- Accepts ETH as payment for digital items
- Integrates with Ethereum smart contracts for payment processing.
- Verifies transactions on-chain for added security
- Allows for the withdrawal of ETH to an Ethereum address
- Utilizes stable memory to keep track of transactions and items.
- Manages a digital storefront with items for sale
- Implements access control to secure sensitive operations
- DFINITY Canister SDK
- Node.js
- Rust
- Basic understanding of Ethereum and smart contracts
We'll begin by cloning the ic-rust-nextjs
repository, which serves as our starter template.
README.md
: Provides an overview and setup instructions.Cargo.toml
: The manifest file for the Rust workspace.dfx.json
: Configuration file for the DFINITY Canister SDK.backend/hello/src/lib.rs
: The Rust code for the backend logic.src/pages/index.tsx
: The main page of the Next.js app.src/service/hello.ts
: Service file to interact with the Rust backend.
To clone the repository, open your terminal and run:
git clone https://github.com/b3hr4d/ic-rust-nextjs.git
After cloning the repository, the next step is to run the project locally to ensure everything is set up correctly. Follow the commands below based on your package manager (Yarn or npm).
First, let's install all the required dependencies:
yarn install:all
# or
npm run install:all
To start the local Internet Computer environment, run:
yarn dfx:start
# or
npm run dfx:start
Deploy your the backend canister to the local Internet Computer by running:
yarn deploy payment
# or
npm run deploy payment
Finally, to run the Next.js(frontend) app, execute:
yarn dev
# or
npm run dev
Open your browser and navigate to http://localhost:3000 to see your app running.
In this step, we'll rename the canister ID to make it easier to reference in the code.
To rename the default project name and canister name from "hello" to "payment", follow these steps:
-
Open the
Cargo.toml
file in the "backend/hello" directory. -
Find the line that says
name = "hello"
and change it toname = "payment"
. -
Save the file.
-
Next, open the
dfx.json
file in the root directory of your project. -
Find the line that says
"hello": {
and change it to"payment": {
. -
Inside the
"payment"
object change "package" from"hello"
to"payment"
and candid from"backend/hello/hello.did"
to"backend/payment/payment.did"
. -
Save the file.
-
Rename the directory
backend/hello
tobackend/payment
. -
Open the
Cargo.toml
file in the root directory again. -
Find the line that says
members = ["backend/hello"]
and change it tomembers = ["backend/payment"]
. -
Save the file.
-
Open the
payment
directory and locate thehello.did
file. -
Ensure that the
.did
file is namedpayment.did
. -
Save any changes if necessary.
With these changes, your project and canister will now be named "payment" instead of "hello".
In this step, we'll modify the backend to include a function that generates a deposit principal from a canister ID. This is essential for converting SepoliaETH into ckSepoliaETH, as per the ckEth documentation.
First, we need to install the b3_utils Rust crate. Open your Cargo.toml
file and add the following line under [dependencies]
:
b3_utils = "0.11.0"
or run this command:
cargo add b3_utils
Replace the existing greet
function with the new deposit_principal
function:
use b3_utils::{vec_to_hex_string_with_0x, Subaccount};
use candid::Principal;
#[ic_cdk::query]
fn deposit_principal(principal: String) -> String {
let principal = Principal::from_text(principal).unwrap();
let subaccount = Subaccount::from_principal(principal);
let bytes32 = subaccount.to_bytes32().unwrap();
vec_to_hex_string_with_0x(bytes32)
}
-
Function Annotation: We use
#[ic_cdk::query]
to indicate that this is a query method, meaning it's read-only and doesn't modify the state. -
Principal Conversion: We convert the passed string into a
Principal
object, which is essential for generating a subaccount. -
Subaccount Generation: We generate a
Subaccount
from thePrincipal
, which is a necessary step for depositing ETH. -
Bytes32 Conversion: We convert the subaccount into a bytes32 array, which is the required format for the smart contract on the Sepolia Ethereum testnet.
-
Hex String: Finally, we convert the bytes32 array into a hex string with a "0x" prefix, which can be used as an argument for the smart contract.
After making the changes to the backend, open another terminal and deploy the canister to your local Internet Computer environment using the following command:
yarn deploy payment
Note: confirm the consent with yes
to the change on the terminal.
This will deploy only the payment
canister, which now includes the deposit_principal
function.
Navigate to the frontend code where the useQueryCall
hook is used. This is typically found in a component file. Change the method from "greet"
to "deposit_principal"
:
const { call, data, error, loading } = useQueryCall({
functionName: "deposit_principal"
})
-
Pass the Canister ID: Update the frontend to include an input field where you can enter the canister ID.
-
Check the Output: The output should be a hexadecimal string that represents the deposit principal, which can be used for depositing ETH.
In this step, we'll integrate MetaMask using the wagmi library and set up the frontend to call the minter helper contract's deposit function.
- Make sure you have the MetaMask extension installed in your browser.
First, install the wagmi
and viem
packages:
yarn add wagmi viem
Create a new file config.ts
inside the src/service
directory and add the following code:
import { createPublicClient, http } from "viem"
import { createConfig, sepolia } from "wagmi"
export const config = createConfig({
chains: [sepolia],
connectors: [injected()],
client({ chain }) {
return createClient({ chain, transport: http() })
}
})
Create a new file named Wallet.tsx
inside the src/components
folder and add the following code:
import { useAccount, useConnect, useDisconnect } from "wagmi"
import { MetaMaskConnector } from "wagmi/connectors/metaMask"
interface WalletProps {}
const Wallet: React.FC<WalletProps> = ({}) => {
const { address } = useAccount()
const { connect } = useConnect({
connector: new MetaMaskConnector()
})
const { disconnect } = useDisconnect()
if (address)
return (
<main>
Connected to: {address}
<br />
<button onClick={() => disconnect()}>Disconnect</button>
</main>
)
return <button onClick={() => connect()}>Connect Wallet</button>
}
export default Wallet
Finally, update your src/pages/index.tsx
file and replace <Greeting />
with the following code`:
// ...existing imports
import Wallet from "../components/Wallet"
import { config } from "service/config"
import { WagmiConfig } from "wagmi"
function HomePage() {
return (
{/* ...existing components */}
{/* <Greeting /> */}
<WagmiConfig config={config}>
<Wallet />
</WagmiConfig>
{/* ...existing components */}
)
}
You should see a "Connect Wallet" button on your browser, similar to the screenshot below.
Clicking on the button should open a MetaMask popup asking for permission to connect. After connecting, you should see your wallet address on the screen.
In this step, we'll prepare the minter helper contract for calls and enable ETH deposits through the frontend.
-
Navigate to Etherscan: Open the contract page on Sepolia Etherscan.
-
Copy Contract ABI: Copy the Contract ABI from the "Contract" tab.
-
Create
abi.json
: Inside thesrc/service
directory, create a new file namedabi.json
and paste the copied ABI.
Create a new file named Deposit.tsx
inside the src/components
directory and add the following code:
import { canisterId } from "declarations/payment"
import { useEffect, useState } from "react"
import helperAbi from "service/abi.json"
import { useQueryCall } from "service/payment"
import { parseEther } from "viem"
import { useContractWrite } from "wagmi"
interface DepositProps {}
const Deposit: React.FC<DepositProps> = ({}) => {
const [amount, setAmount] = useState(0)
const { data: canisterDepositAddress } = useQueryCall({
functionName: "deposit_principal"
})
useEffect(() => {
call(canisterId)
}, [])
const { data, isLoading, write } = useContractWrite({
address: "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34",
abi: helperAbi,
functionName: "deposit",
value: parseEther(amount.toString()),
args: [canisterDepositAddress]
})
const changeHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
let amount = e.target.valueAsNumber
if (Number.isNaN(amount) || amount < 0) amount = 0
setAmount(amount)
}
if (isLoading) {
return <div>Loading...</div>
} else if (data?.hash) {
return <div>Transaction Hash: {data.hash}</div>
} else {
return (
<div>
<input type="number" value={amount} onChange={changeHandler} />
<button onClick={() => write()}>Deposit</button>
</div>
)
}
}
export default Deposit
The useContractWrite
hook is used to interact with Ethereum smart contracts. Here's what each parameter does:
address
: The Ethereum address of the contract you want to interact with.abi
: The ABI (Application Binary Interface) of the contract, which is a JSON representation of the contract's methods and structures.functionName
: The name of the function in the contract that you want to call.value
: The amount of ETH to send along with the function call, converted to its smallest unit (wei) usingparseEther
.args
: An array of arguments that the function takes. In this case, it's the deposit address generated by the canister.
Add the <Deposit />
component to the Wallet.tsx
file, right above the "Disconnect" button:
// ...existing code
return (
<main>
Connected to: {address}
<br />
<Deposit />
<br />
<button onClick={() => disconnect()}>Disconnect</button>
</main>
)
You should have small amount of Sepolia ETH in your wallet. you can get some using this faucet.
- Call the Deposit Function: Please make sure you are on the Sepolia network on the metamask then Use the new deposit input and button to initiate a deposit.
- Confirm with MetaMask: A MetaMask popup should appear asking for confirmation to proceed with the transaction.
- Check the Output: After confirming, you should see a transaction hash.
In this step, we'll implement a mechanism to wait for transaction confirmations before verifying the payment inside the canister.
Create a new file named Confirmation.tsx
inside the src/components
directory and add the following code:
import { Hash } from "viem"
import { useWaitForTransaction } from "wagmi"
interface ConfirmationProps {
hash: Hash;
}
const Confirmation: React.FC<ConfirmationProps> = ({ hash }) => {
const { data, isError, error, isLoading } = useWaitForTransaction({
hash,
confirmations: 6 // 6 confirmations for be sure that the transaction is confirmed
})
if (isError && error) {
return <div>Transaction error {error.toString()}</div>
} else if (isLoading) {
return <div>Waiting for confirmation…</div>
} else if (data) {
return <div>Transaction Status: {data.status}</div>
} else {
return null
}
}
export default Confirmation
The useWaitForTransaction
hook is used to wait for a specified number of confirmations for a given Ethereum transaction hash. Here's what each parameter does:
-
hash
: The transaction hash for which you are waiting for confirmations. -
confirmations
: The number of confirmations to wait for before considering the transaction as confirmed. The default is 1, but in this example, we set it to 6 for added security.
Replace the line that shows the transaction hash with the Confirmation
component:
Change this:
return <div>Transaction Hash: {data.hash}</div>
To this:
return <Confirmation hash={data.hash} />
-
Send Another Transaction: Initiate another deposit transaction.
-
Check the Output: You should see the confirmation process in action. Once the specified number of confirmations is reached, the transaction status will be displayed.
In this step, we'll verify the Ethereum transaction on-chain by calling the Ethereum JSON-RPC API from within the canister.
Add the following dependencies to your Cargo.toml
:
serde = { version = "1.0", features = ["derive"] }
The function eth_get_transaction_receipt
performs the following tasks:
-
Call to EVM RPC Canister: It initiates a call to the EVM RPC canister, utilizing the
eth_get_transaction_receipt
method to retrieve the transaction receipt for a given transaction hash. The function prepares the necessary parameters, including a list of Ethereum Sepolia network services (e.g., PublicNode, BlockPi, Ankr) to ensure reliable data retrieval. -
Handle the RPC Response: The function processes the response from the EVM RPC canister. If the response is consistent across the selected network services, it returns the transaction
receipt
wrapped in anOk
result. If the results are inconsistent or if an error occurs during the RPC call, the function returns an error message wrapped in an Err result. -
Error Handling:: It captures and returns any errors that occur during the process, such as network issues, inconsistencies in the RPC responses, or communication failures, providing detailed error messages for troubleshooting.
Add the following dependency to your Cargo.toml
:
evm-rpc-canister-types = "1.0.0"
Add the follwing code snippet to your dfx.json
file:
"evm_rpc": {
"type": "custom",
"candid": "https://github.com/internet-computer-protocol/evm-rpc-canister/releases/latest/download/evm_rpc.did",
"wasm": "https://github.com/internet-computer-protocol/evm-rpc-canister/releases/latest/download/evm_rpc.wasm.gz",
"remote": {
"id": {
"ic": "7hfb6-caaaa-aaaar-qadga-cai"
}
},
"specified_id": "7hfb6-caaaa-aaaar-qadga-cai",
"init_arg": "(record { nodesInSubnet = 28 })"
}
Use the following types to import the structs from the evm_rpc_canister_types
crate:
// Import the structs from the crate
use evm_rpc_canister_types::{
EthSepoliaService, GetTransactionReceiptResult, MultiGetTransactionReceiptResult, RpcServices,
EVM_RPC,
};
Here's the code snippet for the function:
// Implementing the eth_get_transaction function
async fn eth_get_transaction_receipt(hash: String) -> Result<GetTransactionReceiptResult, String> {
// Make the call to the EVM_RPC canister
let result: Result<(MultiGetTransactionReceiptResult,), String> = EVM_RPC
.eth_get_transaction_receipt(
RpcServices::EthSepolia(Some(vec![
EthSepoliaService::PublicNode,
EthSepoliaService::BlockPi,
EthSepoliaService::Ankr,
])),
None,
hash,
10_000_000_000,
)
.await
.map_err(|e| format!("Failed to call eth_getTransactionReceipt: {:?}", e));
match result {
Ok((MultiGetTransactionReceiptResult::Consistent(receipt),)) => Ok(receipt),
Ok((MultiGetTransactionReceiptResult::Inconsistent(error),)) => Err(format!(
"EVM_RPC returned inconsistent results: {:?}",
error
)),
Err(e) => Err(format!("Error calling EVM_RPC: {}", e)),
}
}
Note: Please always keep ic_cdk::export_candid!();
at the very end of the lib.rs
file.
For testing the function, we'll use the Candid UI, which is a web-based interface for interacting with canisters. It's automatically generated when you deploy a canister using the DFINITY Canister SDK.
Add this function to your lib.rs
file:
// Testing get receipt function
#[ic_cdk::update]
async fn get_receipt(hash: String) -> GetTransactionReceiptResult {
eth_get_transaction_receipt(hash).await.unwrap()
}
-
Deploy the Canister: Deploy the updated canister using the command
yarn deploy evm_rpc && yarn deploy payment
. -
Navigate to Candid UI: After successful deployment, navigate to the Candid UI using the link provided in the terminal. Somthing like this
http://127.0.0.1:4943/?canisterId=bd3sg-teaaa-aaaaa-qaaba-cai&id=bkyz2-fmaaa-aaaaa-qaaaq-cai
-
Test the Function: Inside the Candid UI, you should see the
get_receipt
function. Test it by passing a transaction hash and observing the response.
In this step, we'll create a function to verify Ethereum transactions on-chain. This function will use the logs emitted by the smart contract to verify the transaction details.
The logs' topics are based on the ReceivedEth
event, which has the following signature:
ReceivedEth (index_topic_1 address from, uint256 value, index_topic_2 bytes32 principal)
log.topics[0]
: Event signature hashlog.topics[1]
:from
address (index_topic_1)log.topics[2]
:principal
(index_topic_2)
log.data
:value
(uint256)
Here's the code snippet for the function:
const MINTER_ADDRESS: &str = "0xb44b5e756a894775fc32eddf3314bb1b1944dc34";
use candid::Nat;
use b3_utils::hex_string_with_0x_to_nat;
#[derive(CandidType, Deserialize)]
pub struct VerifiedTransactionDetails {
pub amount: Nat,
pub from: String,
}
#[ic_cdk::update]
async fn verify_transaction(hash: String) -> VerifiedTransactionDetails {
// Get the transaction receipt
let receipt_result = match eth_get_transaction_receipt(hash).await {
Ok(receipt) => receipt,
Err(e) => panic!("Failed to get receipt: {}", e),
};
// Ensure the transaction was successful
let receipt = match receipt_result {
GetTransactionReceiptResult::Ok(Some(receipt)) => receipt,
GetTransactionReceiptResult::Ok(None) => panic!("Receipt is None"),
GetTransactionReceiptResult::Err(e) => {
panic!("Error on Get transaction receipt result: {:?}", e)
}
};
// Check if the status indicates success (Nat 1)
let success_status = Nat::from(1u8);
if receipt.status != success_status {
panic!("Transaction failed");
}
// Verify the 'to' address matches the minter address
if receipt.to != MINTER_ADDRESS {
panic!("Minter address does not match");
}
let deposit_principal = canister_deposit_principal();
// Verify the principal in the logs matches the deposit principal
let log_principal = receipt
.logs
.iter()
.find(|log| log.topics.get(2).map(|topic| topic.as_str()) == Some(&deposit_principal))
.unwrap_or_else(|| panic!("Principal not found in logs"));
// Extract relevant transaction details
let amount = hex_string_with_0x_to_nat(&log_principal.data)
.unwrap_or_else(|e| panic!("Failed to parse amount: {}", e));
let from_address = receipt.from.clone();
VerifiedTransactionDetails {
amount,
from: from_address,
}
}
The function verify_transaction
performs the following tasks:
-
Check Transaction Status: It checks if the transaction was successful by comparing the
status
field to "1". -
Verify Address: It verifies that the
to
address in the transaction and theaddress
in the logs match the minter address. -
Verify Principal: It verifies that the principal in the logs matches the canister's deposit principal. The principal is found in
log.topics[2]
. -
Return Transaction Details: It returns the amount and the sender's address.
For a more robust and secure way, create a new function that returns the canister's deposit principal:
#[ic_cdk::query]
fn canister_deposit_principal() -> String {
let subaccount = Subaccount::from(ic_cdk::id());
let bytes32 = subaccount.to_bytes32().unwrap();
vec_to_hex_string_with_0x(bytes32)
}
-
Deploy the Canister: Deploy the updated canister using
yarn deploy payment
. -
Navigate to Candid UI: After successful deployment, navigate to the Candid UI using the link provided in the terminal.
-
Test the Functions: Inside the Candid UI, you should see the
verify_transaction
andcanister_deposit_principal
functions. Test them by passing a transaction hash and observing the response.
In this step, we'll update the frontend to call the verify_transaction
function after the transaction has been confirmed on-chain.
The VerifyTransaction
component performs the following tasks:
-
Call
verify_transaction
: It calls theverify_transaction
function from the canister when thehash
prop is provided. -
Display Status: It displays the transaction details, including the amount and the sender's address, once the transaction is confirmed on-chain.
Here's the code snippet for the component:
import { useEffect } from "react"
import { useQueryCall } from "service/payment"
import { formatEther } from "viem"
interface VerifyTransactionProps {
hash: string;
}
const VerifyTransaction: React.FC<VerifyTransactionProps> = ({ hash }) => {
const { loading, error, data, call } = useQueryCall({
functionName: "verify_transaction"
})
useEffect(() => {
call([hash])
}, [hash])
if (loading) {
return <div>Processing…</div>
} else if (error) {
return <div>{error.toString()}</div>
} else if (data) {
return (
<div>
Transaction(<b>{hash}</b>) with <b>{formatEther(data[0])}</b>ETH from{" "}
<b>{data[1]}</b> is confirmed on-chain.
</div>
)
} else {
return null
}
}
export default VerifyTransaction
Replace the line that shows the transaction status with the VerifyTransaction
component:
Change this:
return <div>Transaction Status: {data.status}</div>
To this:
return <VerifyTransaction hash={data.transactionHash} />
-
Initiate a Transaction: Initiate a deposit transaction and confirm it.
-
Check the Output: You should see the transaction details displayed once the transaction is confirmed and procceed on-chain.
In this step, we'll deploy our project to the Internet Computer mainnet. This involves a few key steps:
Before deploying to the mainnet, you'll need to ensure that your wallet has enough cycles.
-
Quickstart: Run
dfx quickstart
in your terminal and follow the process to top up your wallet. -
Faucet Cycles: Alternatively, you can get some free cycles from the DFINITY cycles faucet.
Run the following command to deploy your canister to the mainnet:
yarn deploy --network=ic
Alternatively, you can choose to deploy only the backend to the mainnet and run the frontend locally. To deploy just the backend, use:
yarn deploy payment --network=ic
To run the frontend locally, execute:
yarn dev
Upon successful deployment of the backend, you should see output similar to this in your terminal:
-
Open the Frontend: If you've deployed the frontend to the mainnet, navigate to the frontend URL provided in the terminal. If you're running the frontend locally, you can access it via
http://localhost:3000
or the URL provided in your local development server. -
Initiate a Transaction: Initiate a deposit transaction and confirm it.
In this step, we'll integrate our canister with the ckETH ICRC standard to show the balance and enable ETH withdrawal.
First, add the "ledger" feature to the b3_utils
crate in your Cargo.toml
:
b3_utils = { version = "0.11.0", features = ["ledger"] }
Add the following lines at the top of your Rust code to specify the ledger and minter canister IDs:
const LEDGER: &str = "apia6-jaaaa-aaaar-qabma-cai";
const MINTER: &str = "jzenf-aiaaa-aaaar-qaa7q-cai";
The balance
function uses the ICRC1
trait from b3_utils
to fetch the balance of the canister in ckETH.
Here's the code snippet for the function:
use b3_utils::ledger::{ICRCAccount, ICRC1};
use candid::Principal;
#[ic_cdk::update]
async fn balance() -> Nat {
let account = ICRCAccount::new(ic_cdk::id(), None);
ICRC1::from(LEDGER).balance_of(account).await.unwrap()
}
-
Deploy to Mainnet: Run
yarn deploy payment --network=ic
to upgrade canister. -
Open Candid UI: Navigate to the Candid UI and test the
balance
function. Note that the minting process might take some time.
The transfer
function allows the canister to transfer a specified amount of ckETH to another account.
The function uses the ICRC1
trait from b3_utils
to transfer the specified amount of ckETH to the recipient.
Here's the code snippet for the function:
use b3_utils::ledger::{ICRC1TransferArgs, ICRC1TransferResult};
use std::str::FromStr;
#[ic_cdk::update]
async fn transfer(to: String, amount: Nat) -> ICRC1TransferResult {
let to = ICRCAccount::from_str(&to).unwrap();
let transfer_args = ICRC1TransferArgs {
to,
amount,
from_subaccount: None,
fee: None,
memo: None,
created_at_time: None,
};
ICRC1::from(LEDGER).transfer(transfer_args).await.unwrap()
}
-
Deploy to Mainnet: Run
yarn deploy payment --network=ic
to upgrade canister. -
Open Candid UI: Navigate to the Candid UI and test the
transfer
function by passing the recipient's ICRCAccount comptible format string and the amount of ckETH to transfer.
The approve
function uses the ICRC2
trait from b3_utils
to approve the minter to spend your ckETH. This is a one-time action if you approve a large amount.
Here's the code snippet for the function:
use b3_utils::ledger::{ICRC2ApproveArgs, ICRC2ApproveResult, ICRC2};
#[ic_cdk::update]
async fn approve(amount: Nat) -> ICRC2ApproveResult {
let minter = Principal::from_text(&MINTER).unwrap();
let spender = ICRCAccount::new(minter, None);
let args = ICRC2ApproveArgs {
amount,
spender,
created_at_time: None,
expected_allowance: None,
expires_at: None,
fee: None,
memo: None,
from_subaccount: None,
};
ICRC2::from(LEDGER).approve(args).await.unwrap()
}
-
Deploy to Mainnet: Again upgrade the canister using
yarn deploy payment --network=ic
. -
Open Candid UI: Navigate to the Candid UI and test the
approve
function.
In this step, we'll create a withdraw
function that allows users to withdraw ETH from the canister.
First, define some types that will be used for the withdrawal operation. These types are derived from the minter canister.
use candid::{CandidType, Deserialize};
#[derive(CandidType, Deserialize)]
pub struct WithdrawalArg {
pub amount: Nat,
pub recipient: String,
}
#[derive(CandidType, Deserialize, Clone, Debug)]
pub struct RetrieveEthRequest {
pub block_index: Nat,
}
#[derive(CandidType, Deserialize, Debug)]
pub enum WithdrawalError {
AmountTooLow { min_withdrawal_amount: Nat },
InsufficientFunds { balance: Nat },
InsufficientAllowance { allowance: Nat },
TemporarilyUnavailable(String),
}
type WithdrawalResult = Result<RetrieveEthRequest, WithdrawalError>;
The withdraw
function uses the InterCall
trait from b3_utils
to make an internal canister call to the minter canister. The function takes an amount
and a recipient
as arguments and initiates the withdrawal process.
Here's the code snippet for the function:
use b3_utils::InterCall;
#[ic_cdk::update]
async fn withdraw(amount: Nat, recipient: String) -> WithdrawalResult {
let withraw = WithdrawalArg { amount, recipient };
InterCall::from(MINTER)
.call("withdraw_eth", withraw)
.await
.unwrap()
}
-
Deploy to Mainnet: Run
yarn deploy payment --network=ic
. -
Open Candid UI: Navigate to the Candid UI and test the
withdraw
function. Make sure to enter the amount in wei.
In this step, we'll add some security measures and functionalities to our canister.
We'll add guards to the withdraw
and approve
functions to ensure that only the controller can call them. Add the following line at the top of your Rust code:
use b3_utils::caller_is_controller;
Then, add the guard
attribute to the withdraw
and approve
functions:
#[ic_cdk::update(guard = "caller_is_controller")]
To prevent a transaction from being processed more than once, we'll use stable memory. Add the "stable_memory" feature to b3_utils
in your Cargo.toml
:
b3_utils = { version = "0.11.0", features = ["ledger", "stable_memory"] }
Then, add the following code to initialize stable memory:
use b3_utils::memory::init_stable_mem_refcell;
use b3_utils::memory::types::DefaultStableBTreeMap;
use std::cell::RefCell;
thread_local! {
static TRANSACTIONS: RefCell<DefaultStableBTreeMap<String, String>> = init_stable_mem_refcell("trasnactions", 1).unwrap();
static ITEMS: RefCell<DefaultStableBTreeMap<String, u128>> = init_stable_mem_refcell("items", 2).unwrap();
}
We'll add functionalities to set items and their prices, and to get the list of items. Here are the functions:
#[ic_cdk::query]
fn get_transaction_list() -> Vec<(String, String)> {
TRANSACTIONS.with(|t| {
t.borrow()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
}
#[ic_cdk::update(guard = "caller_is_controller")]
fn set_item(item: String, price: u128) {
ITEMS.with(|p| p.borrow_mut().insert(item, price));
}
#[ic_cdk::query]
fn get_items() -> Vec<(String, u128)> {
ITEMS.with(|p| {
p.borrow()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
}
We'll add a function to buy items. This function will check the transaction list to ensure that the transaction has not been processed before. and check the amount to ensure that it's not too low.
Here's the function:
#[ic_cdk::update]
async fn buy_item(item: String, hash: String) -> u64 {
if TRANSACTIONS.with(|t| t.borrow().contains_key(&hash)) {
panic!("Transaction already processed");
}
let price = ITEMS.with(|p| {
p.borrow()
.get(&item)
.unwrap_or_else(|| panic!("Item not found"))
.clone()
});
let verified_details = match verify_transaction(hash.clone()).await {
Ok(details) => details,
Err(e) => panic!("Transaction verification failed: {}", e),
};
let VerifiedTransactionDetails { amount, from } = verified_details;
if amount.parse::<u128>().unwrap_or(0) < price {
panic!("Amount too low");
}
TRANSACTIONS.with(|t| {
let mut t = t.borrow_mut();
t.insert(hash, from);
t.len() as u64
})
}
-
Deploy to Mainnet: Run
yarn deploy payment --network=ic
. -
Testing Guards: Use the terminal to execute functions with guards. For example:
dfx canister call payment withdraw '(10000000000000000, "0xB51f94aEEebE55A3760E8169A22e536eBD3a6DCB")' --network ic
To add a new controller, run:
dfx canister update-settings payment --add-controller 'YOUR_PRINCIPAL' --network=ic
-
Adding Items: Add items using the terminal:
dfx canister call payment set_item '("Pizza", 1000000000000)' --network ic
In this step, we'll integrate the frontend to display a shop and handle item purchases.
Create a new file Shop.tsx
inside the src/components
directory and add the following code:
import { useEffect } from "react"
import { useQueryCall } from "service/payment"
import Item from "./Item"
interface ShopProps {}
const Shop: React.FC<ShopProps> = ({}) => {
const {
data: items,
loading,
call
} = useQueryCall({
functionName: "get_items"
})
return (
<div
style={{
marginTop: 10,
display: "grid",
gridTemplateColumns: "repeat(2, 1fr)",
gridGap: 20
}}
>
{loading ? (
<div>Loading...</div>
) : (
items?.map(([name, price]) => {
return <Item name={name} price={price} key={name} />
})
)}
</div>
)
}
export default Shop
This component fetches the list of items from the backend and displays them in a grid layout.
Create a new file Item.tsx
inside the src/components
directory and add the following code:
import { useEffect } from "react"
import helperAbi from "service/abi.json"
import { useQueryCall } from "service/payment"
import { formatEther } from "viem"
import { useContractWrite } from "wagmi"
import Confirmation from "./Confirmation"
interface ItemProps {
name: string
price: bigint
}
const Item: React.FC<ItemProps> = ({ name, price }) => {
const { data: canisterDepositAddress, call } = useQueryCall(
functionName: "canister_deposit_principal"
)
const { data, isLoading, write } = useContractWrite({
address: "0xb44B5e756A894775FC32EDdf3314Bb1B1944dC34",
abi: helperAbi,
functionName: "deposit",
value: price,
args: [canisterDepositAddress]
})
if (isLoading) {
return <div>Buying {name}…</div>
} else if (data?.hash) {
return <Confirmation hash={data.hash} item={name} />
} else {
return (
<div>
<h3>{name}</h3>
<div>{formatEther(price).toString()} ETH</div>
<button onClick={() => write()}>Buy {name}</button>
</div>
)
}
}
export default Item
This component handles the purchase of individual items. It uses the canister_deposit_principal
and deposit
methods to handle the transaction.
Edit the existing Confirmation.tsx
file to add the item
prop:
import { Hash } from "viem"
import { useWaitForTransaction } from "wagmi"
import VerifyTransaction from "./VerifyTransaction"
interface ConfirmationProps {
item: string
hash: Hash
}
const Confirmation: React.FC<ConfirmationProps> = ({ item, hash }) => {
const { data, isError, error, isLoading } = useWaitForTransaction({
hash,
confirmations: 6
})
if (isError && error) {
return <div>Transaction error {error.toString()}</div>
} else if (isLoading) {
return <div>Waiting for confirmation on Ethereum…</div>
} else if (data) {
return <VerifyTransaction hash={data.transactionHash} item={item} />
} else {
return null
}
}
export default Confirmation
This component waits for the Ethereum transaction to be confirmed and then triggers the on-chain verification on the Internet Computer.
Edit the existing VerifyTransaction.tsx
file to add the item
prop and work with the new buy_item
method:
import { useEffect } from "react"
import { useQueryCall } from "service/payment"
interface VerifyTransactionProps {
item: string
hash: string
}
const VerifyTransaction: React.FC<VerifyTransactionProps> = ({
item,
hash
}) => {
const { loading, error, data, call } = useUpdateCall({
functionName: "buy_item"
})
useEffect(() => {
if (hash) {
call([item, hash])
}
}, [hash])
if (loading) {
return <div>Processing Purchase on ICP...</div>
} else if (error) {
return <div>{error.toString()}</div>
} else if (data) {
return (
<div>
<h3>{item} bought!</h3>
<div>Purchase ID: {data.toString()}</div>
</div>
)
} else {
return null
}
}
export default VerifyTransaction
This component calls the buy_item
method on the backend to finalize the purchase and display a purchase ID.
In your Wallet.tsx
, replace <Deposit />
with <Shop />
.
-
Local Testing: Run
yarn dev
to test the application locally. -
Deploy to Mainnet: Run
yarn deploy --network=ic
to deploy the application to the Internet Computer mainnet. -
Live Example: The live example can be accessed at https://uu4vt-kqaaa-aaaap-abmia-cai.icp0.io/.