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)
+}