diff --git a/.gitignore b/.gitignore index ee8502b..a603807 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ lightclientservice/.idea **/*.so **/.idea/* + +wallet_app/local.properties diff --git a/wallet_app/app/src/main/AndroidManifest.xml b/wallet_app/app/src/main/AndroidManifest.xml index 7b3ef23..8e8508b 100644 --- a/wallet_app/app/src/main/AndroidManifest.xml +++ b/wallet_app/app/src/main/AndroidManifest.xml @@ -27,11 +27,11 @@ { @@ -106,7 +111,7 @@ fun WalletApp(tokenViewModel: TokenViewModel) { } composable { - SendScreen() + SendScreen(walletViewModel) } composable { ReceiveScreen(modifier = Modifier) diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt index 8e1a1eb..6f63522 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletScreen.kt @@ -80,7 +80,8 @@ fun WalletScreen( onNewTokenPress: () -> Unit, onSendPress: () -> Unit, onReceivePress: () -> Unit, - tokenViewModel: TokenViewModel + tokenViewModel: TokenViewModel, + walletViewModel: WalletViewModel ) { Surface(modifier = Modifier.fillMaxSize()) { Wallet( @@ -88,7 +89,8 @@ fun WalletScreen( onNewTokenPress = onNewTokenPress, onSendPress = onSendPress, onReceivePress = onReceivePress, - tokenViewModel = tokenViewModel + tokenViewModel = tokenViewModel, + walletViewModel = walletViewModel ) } } @@ -97,7 +99,7 @@ fun WalletScreen( @SuppressLint("MutableCollectionMutableState") @Composable -fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () -> Unit, onSendPress: () -> Unit,tokenViewModel: TokenViewModel) { +fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () -> Unit, onSendPress: () -> Unit,tokenViewModel: TokenViewModel, walletViewModel: WalletViewModel) { val networkList = listOf("Starknet Mainnet", "Test Networks") var selectedNetworkIndex by remember { mutableStateOf(0) } val coinViewModel: CoinViewModel = viewModel() @@ -105,52 +107,24 @@ fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () - val context = (LocalContext.current as Activity) val address= BuildConfig.ACCOUNT_ADDRESS val accountAddress = Felt.fromHex(address) - val starknetClient = StarknetClient(BuildConfig.RPC_URL) var tokenImages by rememberSaveable { mutableStateOf>(hashMapOf()) } - var balances by remember { mutableStateOf>>(emptyList()) } - val tokenIds = remember { mutableStateListOf() } + val balances by walletViewModel.balances.collectAsState() val coinsPrices by rememberSaveable { mutableStateOf>(hashMapOf()) } var prices by rememberSaveable { mutableStateOf(mapOf()) } - - prices = coinViewModel.prices.value tokenImages=coinViewModel.tokenImages.value - val errorMessage by coinViewModel.errorMessage - LaunchedEffect(tokens) { - if(tokens.isNotEmpty()){ - tokenIds.addAll(tokens.map { it.tokenId }) - coinViewModel.fetchTokenImages(tokenIds) - try { - coinViewModel.getTokenPrices(ids = tokenIds.joinToString(",") { it }, vsCurrencies = "usd") - val balanceDeferred: List>> = tokens.map { token -> - async(Dispatchers.IO) { - try { - val balanceInWei = starknetClient.getBalance(accountAddress, token.contactAddress) - val balanceInEther = weiToEther(balanceInWei).toDoubleWithTwoDecimal() - hashMapOf(token.name to balanceInEther) - } catch (e: RpcRequestFailedException) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "${e.code}: ${e.message}", Toast.LENGTH_LONG).show() - } - hashMapOf(token.name to 0.0) - } - } - } - // Wait for all balance fetching to complete - balances = balanceDeferred.awaitAll() - } catch (e: Exception) { - withContext(Dispatchers.Main) { - Toast.makeText(context, e.message, Toast.LENGTH_LONG).show() - } - } - } + val errorMessageCoinViewModel by coinViewModel.errorMessage + val errorMessageWalletViewModel by walletViewModel.errorMessage; + LaunchedEffect(tokens) { + walletViewModel.fetchBalance(accountAddress, tokens, coinViewModel) } - if (errorMessage.isNotEmpty()) { + + if (errorMessageCoinViewModel.isNotEmpty()) { Text( - text = errorMessage, + text = errorMessageCoinViewModel, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp) ) @@ -163,6 +137,10 @@ fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () - } } + if(errorMessageWalletViewModel.isNotEmpty()) { + Toast.makeText(context, errorMessageWalletViewModel, Toast.LENGTH_LONG).show() + } + Column( modifier = Modifier .fillMaxSize() @@ -179,7 +157,7 @@ fun Wallet(modifier: Modifier, onNewTokenPress: () -> Unit, onReceivePress: () - selectedNetworkIndex, modifier = Modifier, onItemClick = { index -> - selectedNetworkIndex = index + selectedNetworkIndex = index } ) } diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletViewModel.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletViewModel.kt new file mode 100644 index 0000000..ba7a5cd --- /dev/null +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/account/WalletViewModel.kt @@ -0,0 +1,84 @@ +package com.example.walletapp.ui.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import com.example.walletapp.BuildConfig +import com.example.walletapp.model.Token +import com.example.walletapp.utils.StarknetClient +import com.example.walletapp.utils.weiToEther +import com.example.walletapp.utils.toDoubleWithTwoDecimal +import com.swmansion.starknet.account.Account +import com.swmansion.starknet.data.types.Felt +import com.swmansion.starknet.data.types.Uint256 +import com.swmansion.starknet.provider.exceptions.RpcRequestFailedException +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.withContext + + +class WalletViewModel : ViewModel() { + private val _balances = MutableStateFlow(emptyList>()) + val balances: StateFlow>> get() = _balances + val tokenIds = mutableStateListOf() + + private val starknetClient = StarknetClient(BuildConfig.RPC_URL) + + private val _errorMessage = mutableStateOf("") + val errorMessage: State get() = _errorMessage + + fun fetchBalance(accountAddress: Felt, tokens: List, coinViewModel: CoinViewModel) { + viewModelScope.launch { + if (tokens.isNotEmpty()) { + tokenIds.addAll(tokens.map { it.tokenId }) + coinViewModel.fetchTokenImages(tokenIds) + try { + coinViewModel.getTokenPrices( + ids = tokenIds.joinToString(",") { it }, + vsCurrencies = "usd" + ) + val balanceDeferred: List>> = tokens.map { token -> + async(Dispatchers.IO) { + try { + val balanceInWei = + starknetClient.getBalance(accountAddress, token.contactAddress) + val balanceInEther = weiToEther(balanceInWei).toDoubleWithTwoDecimal() + hashMapOf(token.name to balanceInEther) + } catch (e: RpcRequestFailedException) { + withContext(Dispatchers.Main) { + _errorMessage.value = "${e.code}: ${e.message}" + } + hashMapOf(token.name to 0.0) + } + } + } + // Wait for all balance fetching to complete + _balances.value = balanceDeferred.awaitAll() + } catch (e: Exception) { + withContext(Dispatchers.Main) { + _errorMessage.value = e.message.toString() + } + } + } + } + } + + fun transferFunds(account: Account, toAddress: Felt, amount: Uint256) { + viewModelScope.launch { + try { + //TODO Handle the transaction hash if transaction was successfully + starknetClient.transferFunds(account, toAddress, amount) + } catch (e: Exception) { + println(e) + } + } + + } +} diff --git a/wallet_app/app/src/main/java/com/example/walletapp/ui/transfer/SendScreen.kt b/wallet_app/app/src/main/java/com/example/walletapp/ui/transfer/SendScreen.kt index 5429538..c2b95ec 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/ui/transfer/SendScreen.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/ui/transfer/SendScreen.kt @@ -1,5 +1,6 @@ package com.example.walletapp.ui.transfer +import android.app.Activity import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement @@ -38,17 +39,102 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.graphics.toColorInt +import com.example.walletapp.BuildConfig import com.example.walletapp.R +import com.example.walletapp.ui.account.WalletViewModel +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material3.DropdownMenu +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import com.example.walletapp.utils.etherToWei +import com.example.walletapp.utils.isValidEthereumAddress +import com.swmansion.starknet.account.StandardAccount +import com.swmansion.starknet.data.types.CairoVersion +import com.swmansion.starknet.data.types.Felt +import com.swmansion.starknet.extensions.toFelt +import com.swmansion.starknet.provider.rpc.JsonRpcProvider +import com.swmansion.starknet.signer.StarkCurveSigner +import kotlinx.coroutines.future.await +import java.math.BigDecimal + @Composable -fun SendScreen() { - Surface(modifier = Modifier.fillMaxSize()) { - /* TODO(34) send tokens */ +fun SendScreen(walletViewModel: WalletViewModel) { + + val balances by walletViewModel.balances.collectAsState() + val address= BuildConfig.ACCOUNT_ADDRESS + val accountAddress = Felt.fromHex(address) + val temporaryPrivateKey = BuildConfig.PRIVATE_KEY + var amount by rememberSaveable { mutableStateOf("0.00") } + var destinationWallet by rememberSaveable { mutableStateOf("") } + val context = (LocalContext.current as Activity) + var selectedToken by remember { mutableStateOf("ethereum") } + + suspend fun handleOnClick(amount: String, destinationWallet: String) { + try { + val amountValue = amount.toBigDecimalOrNull() + val selectedBalance = balances + .firstOrNull { map -> map[selectedToken] != null } + ?.get(selectedToken) ?: 0.0 - var amount by rememberSaveable { - mutableStateOf("0.00") + if (!isValidEthereumAddress(destinationWallet)){ + Toast.makeText( + context, + "Invalid address", + Toast.LENGTH_LONG + ).show() + } + else if (amountValue == null || amountValue.equals(BigDecimal(0))) { + Toast.makeText( + context, + "Amount cannot be empty", + Toast.LENGTH_LONG + ).show() + } + else if (selectedBalance == 0.0 || selectedBalance <= amount.toDouble()) { + Toast.makeText(context, "Insufficient balance", Toast.LENGTH_LONG).show() + } + else { + // TODO: This should be replaced once we have an account creation or account import feature + val provider = JsonRpcProvider("https://starknet-sepolia.g.alchemy.com/starknet/version/rpc/v0_7/yPM6Zsiftub_GVxfcKGpDzoS1J89DrnZ") + val privateKey = temporaryPrivateKey.toFelt + val signer = StarkCurveSigner(privateKey) + val chainId = provider.getChainId().sendAsync().await() + val standardAccount = StandardAccount( + address = accountAddress, + signer = signer, + provider = provider, + chainId = chainId, + cairoVersion = CairoVersion.ONE, + ) + val toAddress = destinationWallet.toFelt + val amountUint256 = etherToWei(amountValue) + walletViewModel.transferFunds(standardAccount, toAddress, amountUint256) + Toast.makeText(context, "Transaction executed", Toast.LENGTH_LONG).show() + } + } catch (e: Exception) { + Toast.makeText( + context, + e.message, + Toast.LENGTH_LONG + ).show() } + } + var activateHandleOnClick by remember { mutableStateOf(false) } + + if (activateHandleOnClick) { + LaunchedEffect(Unit) { + handleOnClick(amount, destinationWallet) + activateHandleOnClick = false + } + } + + Surface(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() @@ -58,28 +144,84 @@ fun SendScreen() { verticalArrangement = Arrangement.Center ) { Spacer(modifier = Modifier.height(40.dp)) + + // "From" label above the wallet address field (readonly) + Text( + text = "From", + fontFamily = FontFamily(Font(R.font.publicsans_regular)), + color = Color.White, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // From Address TextField (Read-only) + TextField( + value = address, + onValueChange = {}, + textStyle = TextStyle( + fontFamily = FontFamily(Font(R.font.publicsans_regular)), + color = Color.White, + textAlign = TextAlign.Start, + fontSize = 16.sp + ), + enabled = false, // Read-only + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "To", + fontFamily = FontFamily(Font(R.font.publicsans_regular)), + color = Color.White, + fontSize = 14.sp + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Wallet Address TextField + TextField( + value = destinationWallet, + onValueChange = { newValue -> + destinationWallet = newValue + }, + placeholder = { Text("Enter public address (0x)", color = Color.Gray) }, // Placeholder text + textStyle = TextStyle( + fontFamily = FontFamily(Font(R.font.publicsans_regular)), + color = Color.White, + textAlign = TextAlign.Start, + fontSize = 16.sp + ), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, // Text keyboard for wallet address + imeAction = ImeAction.Done + ), + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Description Text + Text( + text = "Amount to send", + fontFamily = FontFamily(Font(R.font.publicsans_regular)), + color = Color.White, + fontSize = 14.sp + ) + // Dropdown button for selecting currency - Button( - onClick = { /* Handle currency selection */ }, - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E1E96)), - modifier = Modifier.padding(8.dp) - ) { - Image( - painter = painterResource(id = R.drawable.ic_ethereum), // Replace with your Ethereum icon - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text(text = "ETH", color = Color.White) - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = Icons.Default.ArrowDropDown, - contentDescription = null, - tint = Color.White + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + TokenDropdown( + selectedToken = selectedToken, + onTokenSelected = { token -> + selectedToken = token + } ) } - - Spacer(modifier = Modifier.height(40.dp)) + Spacer(modifier = Modifier.height(8.dp)) // Amount Text TextField( @@ -99,21 +241,11 @@ fun SendScreen() { singleLine = true, ) - Spacer(modifier = Modifier.height(8.dp)) - - // Description Text - Text( - text = "Amount to send", - fontFamily = FontFamily(Font(R.font.publicsans_regular)), - color = Color.White, - fontSize = 14.sp - ) - Spacer(modifier = Modifier.weight(1f)) // Confirm Button Button( - onClick = { /* Handle confirm action */ }, + onClick = { activateHandleOnClick = true }, colors = ButtonDefaults.buttonColors(containerColor = Color("#1B1B76".toColorInt())), modifier = Modifier .fillMaxWidth() @@ -123,4 +255,79 @@ fun SendScreen() { } } } -} \ No newline at end of file +} + +@Composable +fun TokenDropdown( + selectedToken: String, + onTokenSelected: (String) -> Unit, + modifier: Modifier = Modifier + +) { + var expanded by remember { mutableStateOf(false) } + val selectedTokenToTokenName = mapOf( + "ethereum" to "ETH", + "starknet" to "STRK" + ) + + Box(modifier = modifier) { + Button( + onClick = { expanded = true }, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF1E1E96)), + modifier = Modifier.padding(8.dp) + ) { + if (selectedToken == "ethereum") { + Image( + painter = painterResource(id = R.drawable.ic_ethereum), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } else { + Image( + painter = painterResource(id = R.drawable.starknet_icon), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Text(text = selectedTokenToTokenName.getOrDefault(selectedToken, "ETH"), color = Color.White) + Spacer(modifier = Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.ArrowDropDown, + contentDescription = null, + tint = Color.White + ) + } + + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem(onClick = { + onTokenSelected("ethereum") + expanded = false + }) { + Image( + painter = painterResource(id = R.drawable.ic_ethereum), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("ETH", color = Color.White) + } + DropdownMenuItem(onClick = { + onTokenSelected("starknet") + expanded = false + }) { + Image( + painter = painterResource(id = R.drawable.starknet_icon), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("STRK", color = Color.White) + } + } + } +} + diff --git a/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt b/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt index 94dac78..342fabf 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/utils/StarknetClient.kt @@ -108,9 +108,19 @@ class StarknetClient(private val rpcUrl: String) { ) } - suspend fun transferFunds(account: Account, toAddress: Felt, amount: Uint256) { - // TODO(#102): add logic to transfer funds here - // follow the example: https://github.com/software-mansion/starknet-jvm/blob/main/androiddemo/src/main/java/com/example/androiddemo/MainActivity.kt + suspend fun transferFunds(account: Account, toAddress: Felt, amount: Uint256): Felt { + //TODO: Add support to starknet + val erc20ContractAddress = Felt.fromHex(ETH_ERC20_ADDRESS) + val calldata = listOf(toAddress) + amount.toCalldata() + val call = Call( + contractAddress = erc20ContractAddress, + entrypoint = "transfer", + calldata = calldata, + ) + val request = account.executeV3(call) + val future = request.sendAsync() + val response = future.await() + return response.transactionHash } diff --git a/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt b/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt index 637b304..19e7dd7 100644 --- a/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt +++ b/wallet_app/app/src/main/java/com/example/walletapp/utils/WalletAppUtils.kt @@ -19,3 +19,14 @@ fun weiToEther(wei: Uint256): BigDecimal { val weiInEther = BigDecimal("1000000000000000000") // 10^18 return BigDecimal(wei.value.toString()).divide(weiInEther) } + +fun etherToWei(ether: BigDecimal): Uint256 { + val weiInEther = BigDecimal("1000000000000000000") // 10^18 + val weiValue = ether.multiply(weiInEther).toBigInteger() + return Uint256(weiValue) +} + +fun isValidEthereumAddress(address: String): Boolean { + val regex = Regex("^0x[a-fA-F0-9]+\$") + return regex.matches(address) +}