Skip to main content

Create an Ethereum Web3 wallet in Android

pnpandroidevmkotlinsecp256k1Web3Auth Team | May 27, 2024

In this guide, we'll talk about how we can use Web3Auth to build your Ethereum Web3 wallet in Android. The wallet will only support the Ethereum ecosystem, but functionality can be extended with any blockchain ecosystem.

As an overview, the app is quite simple, with functionality to log in, display user details, and perform blockchain interactions. The signing of the blockchain transactions is done through the Web3Auth embedded wallet. You can check out the infrastructure docs, "Web3Auth Wallet Management Infrastructure" for a high-level overview of the Web3Auth architecture and implementation. For those who want to skip straight to the code, you can find it on GitHub.

Here are a few screenshots of the application.

Android Wallet Screenshots

How to set up Web3Auth Dashboard

If you haven't already, sign up on the Web3Auth platform. It is free and gives you access to the Web3Auth's base plan. After the basic setup, explore other features and functionalities offered by the Web3Auth Dashboard. It includes custom verifiers, whitelabeling, analytics, and more. Head to Web3Auth's documentation page for detailed instructions on setting up the Web3Auth Dashboard.

Integrating Web3Auth in Android

Once, you have set up the Web3Auth Dashboard, and created a new project, it's time to integrate Web3Auth in your Android application. For the implementation, we'll use the "web3auth-android-sdk" SDK. This SDK facilitates integration with Web3Auth. This way you can easily manage an embedded wallet in your Android application.

Installation

To install the web3auth-android-sdk SDK,in your module-level build.gradle or settings.gradle file, add JitPack repository.

dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
maven { url "https://jitpack.io" } // <-- Add this line
}
}

Once, you have added the JitPack repository, then in your app-level build.gradle dependencies section, add the web3auth-android-sdk.

dependencies {
// ...
implementation 'com.github.web3auth:web3auth-android-sdk:7.4.0'
}

For the prerequisites, and other mandatory configuration of the SDK, please head to our installation documentation.

Initialization

After successfully installing the package, the next step is to initialize Web3Auth in your Android app. This sets up the necessary configurations using Client Id and prepares Web3Auth. Learn more about Web3Auth Initialization.

Since we are using the MVVM architecture for the wallet, along with dependency injection, we have defined a Web3AuthHelper to interact with a Web3Auth instance, which also makes it easier to write mocks for unit testing.

class Web3AuthHelperImpl(
private val web3Auth: Web3Auth
): Web3AuthHelper {

// Performs the login to authenticate the user with Web3Auth netowrk.
override suspend fun login(loginParams: LoginParams): CompletableFuture<Web3AuthResponse> {
return web3Auth.login(loginParams)
}

// Logout of the current active session.
override suspend fun logOut(): CompletableFuture<Void> {
return web3Auth.logout()
}

// Returns the Ethereum compatible private key.
override fun getPrivateKey(): String {
return web3Auth.getPrivkey()
}

// Returns the user information such as name, email, profile image, and etc.
// For more details, please checkout UserInfo.
override fun getUserInfo(): UserInfo {
try {
return web3Auth.getUserInfo()!!
} catch (e: Exception) {
throw e
}
}

override suspend fun initialize(): CompletableFuture<Void> {
return web3Auth.initialize()
}

override suspend fun setResultUrl(uri: Uri?) {
return web3Auth.setResultUrl(uri)
}

override suspend fun isUserAuthenticated(): Boolean {
return web3Auth.getPrivkey().isNotEmpty()
}
}

Once we have the created Web3AuthHelper, the next is to initialize the Web3Auth instance in the Koin module and make it a singleton component.

val appModule = module {
single {
getWeb3AuthHelper(get())
}

// Additional code

viewModel { MainViewModel(get()) }
}

private fun getWeb3AuthHelper(context: Context): Web3AuthHelper {
val web3Auth = Web3Auth(
Web3AuthOptions(
clientId = "WEB3AUTH_CLIENT_ID",
context = context,
network = Network.SAPPHIRE_MAINNET,
redirectUrl = Uri.parse("w3a://com.example.android_playground/auth")
)
)

return Web3AuthHelperImpl(web3Auth)
}

Session Management

To check whether the user is authenticated, you can use the getPrivateKey or getEd25519PrivKey method. For a user already authenticated, the result would be a non-empty String. You can navigate to different views based on the result. If the user is already authenticated, we'll generate and prepare the Credentials, important to interact with the blockchain. Along with that, we'll retrieve user info, and navigate them to HomeScreen. In case of no active session, we'll navigate to LoginScreen to authenticate again. Learn more about Web3Auth session management.

Since we are using the MVVM architecture, we'll create a ViewModel class to encapsulate the business logic for Web3Auth and Ethereum chain interaction.

class MainViewModel(private val web3AuthHelper: Web3AuthHelper) : ViewModel() {

// _isLoggedIn can be used in the UI to know whether the user is logged.
private val _isLoggedIn: MutableStateFlow<Boolean> = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn

lateinit var credentials: Credentials
lateinit var userInfo: UserInfo

// Additional code

// Function to retrieve private key.
private fun privateKey(): String {
return web3AuthHelper.getPrivateKey()
}

// prepareCredentials uses the private key to create Ethereum credentials which
// can be used to retrieve the EOA address, and sign the transactions.
private fun prepareCredentials() {
credentials = Credentials.create(privateKey())
}

private fun prepareUserInfo() {
userInfo = web3AuthHelper.getUserInfo()
}

// Additional code

fun initialise() {
viewModelScope.launch {
web3AuthHelper.initialize().await()
isUserLoggedIn()
}
}

private fun isUserLoggedIn() {
viewModelScope.launch {
try {
val isLoggedIn = web3AuthHelper.isUserAuthenticated()
if (isLoggedIn) {
prepareCredentials()
prepareUserInfo()
}
_isLoggedIn.emit(isLoggedIn)
} catch (e: Exception) {
_isLoggedIn.emit(false)
}
}
}
}

Authentication

If the user is not authenticated, we can utilize the login method to authenticate the user. For the Wallet, we will add an Email Passwordless login. We'll create a helper function, login inside MainViewModel. The login method is pretty straightforward in Web3Auth and takes LoginParams as input. After successfully logging in, we'll generate and prepare the Credentials, important to interact with the blockchain. Along with that, we'll retrieve user info, and navigate them to HomeScreen.

Learn more about Web3Auth LoginParams.

class MainViewModel(private val web3AuthHelper: Web3AuthHelper) : ViewModel() {
// Additional code

fun login(email: String) {
val loginParams = LoginParams(
loginProvider = Provider.EMAIL_PASSWORDLESS,
extraLoginOptions = ExtraLoginOptions(login_hint = email)
)
viewModelScope.launch {
try {
web3AuthHelper.login(loginParams = loginParams).await()
// Functions from Session Management code snippets
prepareCredentials()
prepareUserInfo()

// Emit true to navigate to HomeScreen
_isLoggedIn.emit(true)
} catch (error: Exception) {
_isLoggedIn.emit(false)
throw error
}
}
}
}

Set up Blockchain Providers

Once we have successfully authenticated the user, the next step would be to fetch the user details, retrieve the wallet address, and prepare blockchain providers for interactions. For this guide, we are supporting only the Ethereum ecosystem, but the general idea can be used for any blockchain ecosystem.

Given that the project follows MVVM architecture pattern, we'll want to create a UseCase to interact with the Blockchain. This UseCase will help us easily expand the blockchain support while isolating it from the rest of the application.

For interacting with Ethereum chains, we'll use the web3j SDK.

To install the web3j SDK, in your module-level build.gradle or settings.gradle file, add web3j in your app-level dependencies.

dependencies {
// ...
implementation 'org.web3j:core:4.8.7-android'
}

After successfully installing the SDK, it's time to set up our Ethereum UseCase. First, we'll create a new class, EthereumUseCase interface, which will used as a base class for EthereumUseCaseImpl. If you wish to support any additional ecosystem, you can create the chain-agnostic UseCase and implement the methods.

If you want to learn, how you can integrate different blockchains with Web3Auth, you can check out our Connect Blockchain resources.

interface EthereumUseCase {
suspend fun getBalance(publicKey: String): String
suspend fun signMessage(message: String, sender: Credentials): String
suspend fun sendETH(amount: String, recipientAddress: String, sender: Credentials): String

suspend fun getBalanceOf(contractAddress: String, address: String, credentials: Credentials): String
suspend fun approve(contractAddress: String, spenderAddress: String, credentials: Credentials): String
}

Generally, for any blockchain provider, you'll only require the getBalance, sendTransaction, and signMessage. The getBalance and approve can be used to interact with smart contracts. To interact with smart contracts, we'll be required to generate smart contract function wrappers in Java from Solidity ABI files.

Smart Contract Wrappers

For generating the wrappers, we'll use the web3j command line tools.

To install the web3j cli, you can use the below command. Read more about web3j cli installation.

curl -L get.web3j.io | sh && source ~/.web3j/source.sh

Once, we have installed the cli, the next step is to create Token.sol file, which has the smart contract interface for the ERC-20 token. Learn more about ERC-20 token standard.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount)
external
returns (bool);
function allowance(address owner, address spender)
external
view
returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount)
external
returns (bool);
}

After creating the interface for the ERC-20 token, the next step is to compile the solidity file and generate the abi and bin files to generate the wrappers. To compile the solidity file, we'll use the solc.

To install the solc we'll require the npm or yarn. If you have the npm already installed, you can use the below command to install the solc package globally.

npm install -g solc

Once, we have the solc package installed, we'll compile the smart contract. The bin and abi options will generate the abi and bin files. Feel free to choose the output directory of your choice.

solc Token.sol --bin --abi --optimize -o <output-dir>/

Once, we have compiled the smart contract, the next step is to use the web3j cli to generat the wrpapers.

web3j generate solidity -b /path/to/Tokne.bin -a /path/to/Token..abi -o /path/to/src/main/java -p com.your.organisation.name

Once you run the command, it'll create a wrapper Token.java which extends the Contract. You can use this class to interact with the smart contracts. Please make sure to compile and regenerate wrappers if you make any changes in the smart contract.

Ethereum UseCase Implementation

Once we have generated Token wrapper, we'll create EthereumUseCaseImpl and implement the methods. To create the Web3j instance, you'll require the rpcTarget URL. If you are using public RPCs, you can face some network congestion. It's ideal to use paid RPCs for production.

The getBalance, and approve methods are used to interact with smart contracts in the Ethereum ecosystem. The getBalance is used to read the balance from the ERC-20 smart contracts, whereas the approve is used to change the approval to zero for the ERC-20. For the getBalance and approve we'll be using the Token wrapper.

class EthereumUseCaseImpl(
private val web3: Web3j
) : EthereumUseCase {
override suspend fun getBalance(publicKey: String): String = withContext(Dispatchers.IO) {
try {
val balanceResponse = web3.ethGetBalance(publicKey, DefaultBlockParameterName.LATEST).send()
val ethBalance = BigDecimal.valueOf(balanceResponse.balance.toDouble()).divide(BigDecimal.TEN.pow(18))
DecimalFormat("#,##0.00000").format(ethBalance)
} catch (e: Exception) {
throw e
}
}

override suspend fun signMessage(message: String, sender: Credentials): String {
try {
val signature = Sign.signPrefixedMessage(message.toByteArray(), sender.ecKeyPair)
val r = Numeric.toHexString(signature.r)
val s = Numeric.toHexString(signature.s).substring(2)
val v = Numeric.toHexString(signature.v).substring(2)

return StringBuilder(r).append(s).append(v).toString()
} catch (e: Exception) {
throw e
}
}

override suspend fun sendETH(amount: String, recipientAddress: String, sender: Credentials): String {
try {


val ethGetTransactionCount: EthGetTransactionCount =
web3.ethGetTransactionCount(sender.address, DefaultBlockParameterName.LATEST)
.sendAsync().get()
val nonce: BigInteger = ethGetTransactionCount.transactionCount
val value: BigInteger = Convert.toWei(amount, Convert.Unit.ETHER).toBigInteger()
val gasLimit: BigInteger = BigInteger.valueOf(21000)
val gasPrice = web3.ethGasPrice().sendAsync().get()


val rawTransaction: RawTransaction = RawTransaction.createEtherTransaction(
nonce,
gasPrice.gasPrice,
gasLimit,
recipientAddress,
value
)

val signedMessage: ByteArray = TransactionEncoder.signMessage(rawTransaction, sender)
val hexValue: String = Numeric.toHexString(signedMessage)
val ethSendTransaction: EthSendTransaction =
web3.ethSendRawTransaction(hexValue).sendAsync().get()

if (ethSendTransaction.error != null) {
throw Exception(ethSendTransaction.error.message)
} else {
return ethSendTransaction.transactionHash
}
} catch (e: Exception) {
throw e
}
}

override suspend fun getBalanceOf(contractAddress: String, address: String, credentials: Credentials): String = withContext(Dispatchers.IO) {
val token = Token.load(contractAddress, web3, credentials, DefaultGasProvider())
val balanceResponse = token.balanceOf(address).sendAsync().get()
BigDecimal.valueOf(balanceResponse.toDouble()).divide(BigDecimal.TEN.pow(18)).toString()
}

override suspend fun approve(
contractAddress: String,
spenderAddress: String,
credentials: Credentials
): String = withContext(Dispatchers.IO) {
val token = Token.load(contractAddress, web3, credentials, DefaultGasProvider())
val hash = token.approve(spenderAddress, BigInteger.ZERO).sendAsync().get()
hash.transactionHash
}
}

Once we have the created EthereumUseCaseImpl, next is to initialize the EthereumUseCaseImpl instance in the Koin module.

val appModule = module {
// Additional code

factory<EthereumUseCase> { EthereumUseCaseImpl(Web3j.build(HttpService(chainConfigList.first().rpcTarget))) }

// Additonal code

Set up Supported Chains

After having our blockchain UseCase in place, the next step on the list is to define the supported chains. To keep things simple, we'll simply create a new file ChainConfigList with an array of ChainConfig to define the supported chains.

For the guide, we have added the support for Ethereum Sepolia, and Arbitrum Sepolia. If you wish to support more chains in your wallet, you can simply add the config with the required details in the list below. Along with that, you can also add the desired chain using the add custom chain feature in the app.

var chainConfigList = arrayOf(
ChainConfig(
chainNamespace = ChainNamespace.EIP155,
decimals = 18,
blockExplorerUrl = "https://sepolia.etherscan.io/",
chainId = "11155111",
displayName = "Ethereum Sepolia",
rpcTarget = "https://1rpc.io/sepolia",
ticker = "ETH",
tickerName = "Ethereum"
),
ChainConfig(
chainNamespace = ChainNamespace.EIP155,
decimals = 18,
blockExplorerUrl = "https://sepolia.etherscan.io/",
chainId = "421614",
displayName = "Arbitrum Sepolia",
rpcTarget = "https://endpoints.omniatech.io/v1/arbitrum/sepolia/public",
ticker = "ETH",
tickerName = "Ethereum"
)
)

Wallet Implementation

Once, we have set up the EthereumUseCase, and supported chains, it's time to integrate and plug them into the wallet. Since we have already created MainViewModel before, we'll add the other features inside it.

This will help us to separate business logic from UI.

Set up MainViewModel

Once we have set up supported chains, the next on the list is to add more functionality in MinaViewModel to help us manage the state & functionality of the wallet. It will help us manage the state of the currently selected chain, fetch balance, sign transactions, and access other functionalities of Web3Auth.

class MainViewModel(private val web3AuthHelper: Web3AuthHelper) : ViewModel() {
// _isLoggedIn can be used in the UI to know whether the user is logged.
private val _isLoggedIn: MutableStateFlow<Boolean> = MutableStateFlow(false)
val isLoggedIn: StateFlow<Boolean> = _isLoggedIn

// _isAccountLoaded can be used in the UI to know whether the user's account is loaded.
// If it's false, we'll show the loading indictor.
private val _isAccountLoaded: MutableStateFlow<Boolean> = MutableStateFlow(false)
val isAccountLoaded: StateFlow<Boolean> = _isAccountLoaded

// _balance holds the user's balance for the selected ChainConfig.
private val _balance: MutableStateFlow<String> = MutableStateFlow("0.0")
val balance: StateFlow<String> = _balance


// Currently selected ChainConfig by the user. By default, it would be the first ChainConfig
// in the list.
private val _selectedChain: MutableStateFlow<ChainConfig> = MutableStateFlow(chainConfigList[0])
val selectedChain: StateFlow<ChainConfig> = _selectedChain

// Credentials will be used to retrive user's EOA address, and sign the transactions.
lateinit var credentials: Credentials
lateinit var userInfo: UserInfo

// EthereumUseCaseImpl to interact with the selected Ethereum ChainConfig.
private var ethereumUseCase: EthereumUseCase = EthereumUseCaseImpl(
Web3j.build(
HttpService(
chainConfigList.first().rpcTarget
)
)
)

// User's Ethereum compatible private key.
private fun privateKey(): String {
return web3AuthHelper.getPrivateKey()
}

private fun prepareCredentials() {
credentials = Credentials.create(privateKey())
}

private fun prepareUserInfo() {
userInfo = web3AuthHelper.getUserInfo()
}

fun login(email: String) {
// Defined previously
}

fun initialise() {
// Defined previously
}

private fun isUserLoggedIn() {
// Defined previously
}

// Retrieves user's balance for the currently selected ChainConfig.
fun getBalance() {
viewModelScope.launch {
_isAccountLoaded.emit(false)
try {
Log.d("Address", credentials.address)
_balance.emit(ethereumUseCase.getBalance(credentials.address))
_isAccountLoaded.emit(true)
} catch (e: Exception) {
_isAccountLoaded.emit(false)
throw e
}
}
}

// Logouts out user, and deletes the currently active session.
fun logOut() {
viewModelScope.launch {
try {
web3AuthHelper.logOut().await()
_isLoggedIn.emit(true)
} catch (e: Exception) {
_isLoggedIn.emit(false)
}
}
}

// Signs and broadcast a trasnfer transaction.
fun sendTransaction(value: String, recipient: String, onSign: (hash: String?, error: String?) -> Unit) {
viewModelScope.launch {
try {
val hash = ethereumUseCase.sendETH(value, recipient, credentials)
onSign(hash, null)
} catch (e: Exception) {
e.localizedMessage?.let { onSign(null, it) }
}
}
}

// Signs a personal message.
fun signMessage(message: String, onSign: (hash: String?, error: String?) -> Unit) {
viewModelScope.launch {
try {
val signature = ethereumUseCase.signMessage(message, credentials)
Log.d("Signature", signature)
onSign(signature, null)
} catch (e: Exception) {
e.localizedMessage?.let { onSign(null, it) }
}
}
}

// Changes the currently selected ChainConfig.
fun changeChainConfig(config: ChainConfig) {
_selectedChain.value = config
ethereumUseCase = EthereumUseCaseImpl(
Web3j.build(
HttpService(
config.rpcTarget
)
)
)
getBalance()
}

// Retreives the ERC-20 token balance using the getBalanceOf method.
fun getTokenBalance(contractAddress: String, onSuccess: (balance: String?, error: String?) -> Unit) {
viewModelScope.launch {
try {
val balance = ethereumUseCase.getBalanceOf(contractAddress, credentials.address, credentials)
Log.d("Token Balance:",balance)
onSuccess(balance, null)
} catch (e: Exception) {
onSuccess(null, e.localizedMessage)
}
}
}

// Revokes the approval for the ERC-20 token using the approve function.
fun revokeApproval(contractAddress: String, spenderAddress: String, onRevoke: (hash: String?, error: String?) -> Unit) {
viewModelScope.launch {
try {
val hash = ethereumUseCase.approve(contractAddress, spenderAddress, credentials)
Log.d("Revoke Hash:", hash)
onRevoke(hash, null)
} catch (e: Exception) {
onRevoke(null, e.localizedMessage)
}
}
}

fun userInfo(onAvailable: (userInfo: UserInfo?, error: String?) -> Unit) {
try {
val info = web3AuthHelper.getUserInfo()
onAvailable(info, null)
} catch (e: Exception) {
e.localizedMessage?.let { onAvailable(null, it) }
}
}
}

Set up Home screen

Once, we have our view model ready, we create a new HomeScreen to show user details as email address, wallet address, user's balance for selectedChain, and blockchain interaction methods.

To get the user's balance, we'll use getBalance method from the MainViewModel. The method internally uses EthereumUseCaseImpl to retrieve the user's wallet address and fetch the wallet balance for the address. Check out EthereumUseCaseImpl implementation for more details.

For the bottom navigation, we have created TabBarView, please check TabBarView.kt file for more details on UI implementation.

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(viewModel: MainViewModel) {
val homeTab = TabBarItem(
title = "Home",
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home
)
val alertsTab = TabBarItem(
title = "Sign & Send",
selectedIcon = Icons.Filled.Create,
unselectedIcon = Icons.Outlined.Create
)
val settingsTab = TabBarItem(
title = "Smart Contracts",
selectedIcon = Icons.Filled.Receipt,
unselectedIcon = Icons.Outlined.Receipt
)

val tabBarItems = listOf(homeTab, alertsTab, settingsTab)

val navController = rememberNavController()

// Show the UI if the account is loaded, otherwise show the
// progress indictor.
if (viewModel.isAccountLoaded.collectAsState().value) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(text = "Android Playground")
},

actions = {
Row {
// Logs out user
IconButton(onClick = { viewModel.logOut() }) {
Icon(Icons.Outlined.ExitToApp, contentDescription = "Logout")
}
}
}
)
},
bottomBar = {
TabView(tabBarItems = tabBarItems, navController = navController)
}
) { innerPadding ->
// Different Views which will be shown upon user selection. By default, it'll be AccountView.
NavHost(navController = navController, startDestination = "Home", modifier = Modifier.padding(innerPadding)) {
composable(homeTab.title) {
AccountView(viewModel = viewModel)
}
composable(alertsTab.title) {
TransactionScreen(viewModel = viewModel)
}
composable(settingsTab.title) {
SmartContractsScreen(viewModel = viewModel)
}
}
}
} else {
// Shows CircularProgressIndicator
Box(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}

@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class)
@Composable
fun AccountView(viewModel: MainViewModel) {
// Used for ExposedDropdownMenuBox state management
var expanded by remember { mutableStateOf(false) }

// Defines whether to showcase user info dialog. By default,
// it's false.
val openUserInfoDialog = remember {
mutableStateOf(false)
}

var balance = viewModel.balance.collectAsState().value
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val refreshing by viewModel.isAccountLoaded.collectAsState()

val pullRefreshState = rememberPullRefreshState(!refreshing, { viewModel.getBalance() })

// Displays UserInfoDialog when openUserInfoDialog is true.
if(openUserInfoDialog.value) {
UserInfoDialog(onDismissRequest = {
openUserInfoDialog.value = false
}, userInfo = viewModel.userInfo.toString())
}

Box(Modifier.pullRefresh(pullRefreshState)) {
LazyColumn(
modifier = Modifier
.padding(PaddingValues(horizontal = 16.dp, vertical = 8.dp))
) {
item {
// Additional UI
Box(
modifier = Modifier
.fillMaxWidth()
) {
// Dropdown for chain selection
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
expanded = !expanded
}
) {
OutlinedTextField(
value = viewModel.selectedChain.collectAsState().value.displayName!!,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)

ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
chainConfigList.forEach { item ->
DropdownMenuItem(
text = { Text(text = item.displayName!!) },
onClick = {
expanded = false
viewModel.changeChainConfig(item)
}
)
}
}
}
}
// Additonal UI code

// Display User info
Row {
Box(
modifier = Modifier
.height(120.dp)
.width(120.dp)
.background(color = MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Text(
text = viewModel.userInfo.name.first().uppercase(),
style = Typography.headlineLarge.copy(color = Color.White)
)
}

Box(modifier = Modifier.width(16.dp))
Column {
Text(text = viewModel.userInfo.name, style = Typography.titleLarge)
Box(modifier = Modifier.height(12.dp))
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Displays user's EOA address
Text(
text = viewModel.credentials.address.addressAbbreviation(),
style = Typography.titleMedium
)
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(viewModel.credentials.address))
}) {
Icon(Icons.Outlined.ContentCopy, contentDescription = "Copy")
}
}
}
}
// Additional UI code
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
// Displays user's wallet balance for selected ChainConfig.
Text(text = "Wallet Balance", style = Typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(text = balance, style = Typography.headlineSmall)
}
Column(horizontalAlignment = Alignment.End) {
// Displays the chainId for selected ChainConfig
Text(text = "Chain id", style = Typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(text = viewModel.selectedChain.collectAsState().value.chainId, style = Typography.headlineSmall)
}
}
}
}

// Adds additional pull to refresh functionality
PullRefreshIndicator(!refreshing, pullRefreshState, Modifier.align(Alignment.TopCenter))
}
}

Chain Interactions

Once we have set up HomeScreen and AccountView, the next step is to set up chain interactions for signing messages, signing transactions, reading from contracts, and writing on contracts. For signing messages and transaction, we'll create a new TransactionScreen widget and utilize signMessage and sendTransaction from MainViewModel for the respective functionality.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun TransactionScreen(viewModel: MainViewModel) {
val pagerState = rememberPagerState( 0)
val tabItems = listOf(
"Sign Message",
"Send Transaction",
)

Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Signing/Transaction", style = Typography.headlineLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Tabs(pagerState = pagerState, tabItems)
Spacer(modifier = Modifier.height(16.dp))
TabsContent(pagerState = pagerState, viewModel)
}
}

@OptIn(ExperimentalPagerApi::class)
@Composable
fun TabsContent(pagerState: PagerState, viewModel: MainViewModel) {

HorizontalPager(state = pagerState, count = 2) {
page ->
when (page) {
0 -> SigningView(viewModel = viewModel)
1 -> TransactionView(viewModel = viewModel)
}
}
}

@Composable
fun SigningView(viewModel: MainViewModel) {
// Default signing message
var messageText by remember { mutableStateOf("Welcome to Web3Auth") }
val openAlertDialog = remember { mutableStateOf(false) }
var dialogText by remember { mutableStateOf("") }

when {
openAlertDialog.value -> MinimalDialog(dialogText) {
openAlertDialog.value = false
}
}

Column(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)) {
// Additional UI code
Button(onClick = {
// Signs the message and show the signature
viewModel.signMessage(messageText, onSign = {
signature, error ->
if(signature != null) {
dialogText = "Signature:\n$signature"
openAlertDialog.value = true
} else {
dialogText = "Error:\n$error"
openAlertDialog.value = true

}
})
}, shape = RoundedCornerShape(4.dp), modifier = Modifier.fillMaxWidth()) {
Text("Sign Message")
}

}
}


@Composable
fun TransactionView(viewModel: MainViewModel) {
var valueText by remember { mutableStateOf("") }
var addressText by remember { mutableStateOf("") }
val openAlertDialog = remember { mutableStateOf(false) }
var dialogText by remember { mutableStateOf("") }

when {
openAlertDialog.value -> MinimalDialog(dialogText) {
openAlertDialog.value = false
}
}

Column(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)) {
// Additional UI code
Button(onClick = {
// Performs transfer transaction and displays the hash for the transaction
viewModel.sendTransaction(valueText, addressText, onSign = {
hash, error ->
if(hash != null) {
dialogText = "Hash:\n$hash"
openAlertDialog.value = true
} else {
dialogText = "Error:\n$error"
openAlertDialog.value = true
}
})
}, shape = RoundedCornerShape(4.dp), modifier = Modifier.fillMaxWidth()) {
Text("Send transaction")
}
}
}

Once we have set up TransactionScreen, the next is to create SmartContractsScreen for fetching ERC-20 token balance, and revoking approval. We'll utilize the getTokenBalance and revokeApproval methods from MainViewModel for the above functionality.

@OptIn(ExperimentalPagerApi::class)
@Composable
fun SmartContractsScreen(viewModel: MainViewModel) {
val pagerState = rememberPagerState( 0)
val tabItems = listOf(
"Read from Contract",
"Write from Contract",
)

Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Smart Contract Interactions", style = Typography.headlineLarge,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Tabs(pagerState = pagerState, tabItems)
Spacer(modifier = Modifier.height(16.dp))
ContractTabsContent(pagerState = pagerState, viewModel)
}
}


@Composable
@OptIn(ExperimentalPagerApi::class)
fun ContractTabsContent(pagerState: PagerState, viewModel: MainViewModel) {
HorizontalPager(state = pagerState, count = 2) {
page ->
when (page) {
0 -> ReadContractView(viewModel = viewModel)
1 -> WriteContractView(viewModel = viewModel)
}
}
}

@Composable
fun ReadContractView(viewModel: MainViewModel) {
var contractAddressText by remember { mutableStateOf("0x10279e6333f9d0EE103F4715b8aaEA75BE61464C") }
val openAlertDialog = remember { mutableStateOf(false) }
var dialogText by remember { mutableStateOf("") }

when {
openAlertDialog.value -> MinimalDialog(dialogText) {
openAlertDialog.value = false
}
}

Column(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)) {
// Additional code
Button(onClick = {
// Retrieves ERC-20 token balance for the user's EOA address
viewModel.getTokenBalance(contractAddressText, onSuccess = {
balance, error ->
if(balance != null) {
dialogText = "Balance:\n$balance"
openAlertDialog.value = true
} else {
dialogText = "Error:\n$error"
openAlertDialog.value = true
}
})
}, shape = RoundedCornerShape(4.dp), modifier = Modifier.fillMaxWidth()) {
Text("Fetch Balance")
}
}
}

@Composable
fun WriteContractView(viewModel: MainViewModel) {
var contractAddressText by remember { mutableStateOf("0x10279e6333f9d0EE103F4715b8aaEA75BE61464C") }
var spenderAddressText by remember { mutableStateOf("") }
val openAlertDialog = remember { mutableStateOf(false) }
var dialogText by remember { mutableStateOf("") }

when {
openAlertDialog.value -> MinimalDialog(dialogText) {
openAlertDialog.value = false
}
}

Column(modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp)) {
// Additional code
Button(onClick = {
// Revokes the approval of ERC-20 token for respective spenderAddress.
viewModel.revokeApproval(contractAddressText, spenderAddressText, onRevoke = {
hash, error ->
if(hash != null) {
dialogText = "Hash:\n$hash"
openAlertDialog.value = true
} else {
dialogText = "Error:\n$error"
openAlertDialog.value = true
}
})
}, shape = RoundedCornerShape(4.dp), modifier = Modifier.fillMaxWidth()) {
Text("Revoke Approval")
}

}
}

Conclusion

Voila, you have build a Ethereum Web3 wallet. This guide only gives you an overview of how to create your wallet with Ethereum ecosystem support. The general idea of the guide can be used for any of the blockchain ecosystem.

If you are interested in learning more about Web3Auth, please checkout our documentation for Android. You can find the code used for the guide on our examples repo.