diff --git a/wallet-sdk/app/build.gradle.kts b/wallet-sdk/app/build.gradle.kts index 0222ea7..ce8ba0f 100644 --- a/wallet-sdk/app/build.gradle.kts +++ b/wallet-sdk/app/build.gradle.kts @@ -1,11 +1,18 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) + id("kotlin-kapt") } android { namespace = "com.snphone.snwalletsdk" - compileSdk = 34 + compileSdk = 35 + + buildFeatures { + buildConfig = true + } defaultConfig { applicationId = "com.snphone.snwalletsdk" @@ -15,6 +22,14 @@ android { versionName = "1.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + val properties = Properties() + properties.load(project.rootProject.file("local.properties").inputStream()) + buildConfigField("String", "RPC_URL", "\"${properties.getProperty("RPC_URL")}\"") + buildConfigField("String", "publicAddress", "\"${properties.getProperty("publicAddress")}\"") + buildConfigField("String", "privateKey", "\"${properties.getProperty("privateKey")}\"") + buildConfigField("String", "recepientAddress", "\"${properties.getProperty("recepientAddress")}\"") + } buildTypes { @@ -27,20 +42,38 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.7" } } dependencies { + implementation("com.swmansion.starknet:starknet:0.13.0@aar"){ + isTransitive = true + } + + //Room + implementation(libs.androidx.room.runtime) + implementation(libs.androidx.activity) + implementation(libs.androidx.constraintlayout) + kapt(libs.androidx.room.compiler) + implementation(libs.androidx.room.ktx) + + //crypto-security + implementation(libs.androidx.security.crypto.v110alpha06) + implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + testImplementation(libs.kotlinx.coroutines.test) } \ No newline at end of file diff --git a/wallet-sdk/app/src/main/AndroidManifest.xml b/wallet-sdk/app/src/main/AndroidManifest.xml index 5a259f4..7bc602a 100644 --- a/wallet-sdk/app/src/main/AndroidManifest.xml +++ b/wallet-sdk/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + tools:targetApi="31"> + + + + + + + + \ No newline at end of file diff --git a/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/MainActivity.kt b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/MainActivity.kt new file mode 100644 index 0000000..b40ffb9 --- /dev/null +++ b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/MainActivity.kt @@ -0,0 +1,67 @@ +package com.snphone.snwalletsdk + +import android.content.Context +import android.os.Bundle +import android.util.Log +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import com.snphone.snwalletsdk.utils.StarknetClient +import com.swmansion.starknet.account.StandardAccount +import com.swmansion.starknet.data.types.Felt +import com.swmansion.starknet.data.types.StarknetChainId +import com.swmansion.starknet.extensions.toFelt +import com.swmansion.starknet.provider.rpc.JsonRpcProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MainActivity : AppCompatActivity() { + + init { + instance = this + } + + companion object { + private var instance: MainActivity? = null + + fun applicationContext() : Context { + return instance!!.applicationContext + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + instance = this + setContentView(R.layout.activity_main) + + val starknetClient = StarknetClient(BuildConfig.RPC_URL) + lifecycleScope.launch(Dispatchers.IO) { + + //Deploy account + //val (privateKey, accountAddress) = deployAccount() + + //GetBalance + //val balance = getBalance(accountAddress) + val addressUser = BuildConfig.publicAddress.toFelt + val privateKey = BuildConfig.privateKey.toFelt + + val account = StandardAccount( + address = addressUser, + privateKey = privateKey, + provider = JsonRpcProvider(BuildConfig.RPC_URL), + chainId = StarknetChainId.SEPOLIA, + ) + val recipientAddress = Felt.fromHex(BuildConfig.recepientAddress) + try { + val amount = StarknetClient.toUint256Amount(1.toString()) + val address = starknetClient.transferFunds(account,recipientAddress,amount) + Log.d("MainActivity", "transaction id: $address") + + }catch (e: Exception){ + Log.d("MainActivity", "Error in amount: $e") + + } + } + } +} \ No newline at end of file diff --git a/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/SNWalletSDK.kt b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/SNWalletSDK.kt index 8bbdaa7..8a66d40 100644 --- a/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/SNWalletSDK.kt +++ b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/SNWalletSDK.kt @@ -1,4 +1,32 @@ package com.snphone.snwalletsdk -class SNWalletSDK { +import android.content.Context +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.snphone.snwalletsdk.utils.StarknetClient +import kotlinx.coroutines.launch + +class SNWalletSDK : ComponentActivity() { + init { + instance = this + } + + companion object { + private var instance: SNWalletSDK? = null + + fun applicationContext() : Context { + return instance!!.applicationContext + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + instance = this + + val starknetClient = StarknetClient(BuildConfig.RPC_URL) + lifecycleScope.launch { + starknetClient.deployAccount() + } + } } \ No newline at end of file diff --git a/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/utils/Keystore.kt b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/utils/Keystore.kt new file mode 100644 index 0000000..825e3d1 --- /dev/null +++ b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/utils/Keystore.kt @@ -0,0 +1,64 @@ +package com.snphone.snwalletsdk.utils + +import android.util.Log +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import com.snphone.snwalletsdk.MainActivity +import com.snphone.snwalletsdk.SNWalletSDK +import java.io.IOException +import java.security.GeneralSecurityException + +class Keystore() { + + private val tag = "Keystore" + private val context = MainActivity.applicationContext() + fun storeData(message: String) { + try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val sharedPreferences = EncryptedSharedPreferences.create( + context, + "my_encrypted_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + val editor = sharedPreferences.edit() + editor.putString("key", message) + editor.apply() + + } catch (e: GeneralSecurityException) { + Log.e(tag, "Security exception while storing data: ${e.message}", e) + } catch (e: IOException) { + Log.e(tag, "I/O exception while storing data: ${e.message}", e) + } + } + + fun retrieveData(): String { + try { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + val sharedPreferences = EncryptedSharedPreferences.create( + context, + "my_encrypted_prefs", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + + return sharedPreferences.getString("key", "") ?: "" + + } catch (e: GeneralSecurityException) { + Log.e(tag, "Security exception while retrieving data: ${e.message}", e) + return "" + } catch (e: IOException) { + Log.e(tag, "I/O exception while retrieving data: ${e.message}", e) + return "" + } + } +} \ No newline at end of file diff --git a/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/utils/StarknetClient.kt b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/utils/StarknetClient.kt new file mode 100644 index 0000000..884a1f6 --- /dev/null +++ b/wallet-sdk/app/src/main/java/com/snphone/snwalletsdk/utils/StarknetClient.kt @@ -0,0 +1,180 @@ +package com.snphone.snwalletsdk.utils + +import android.util.Log +import com.swmansion.starknet.account.Account +import com.swmansion.starknet.account.StandardAccount +import com.swmansion.starknet.crypto.StarknetCurve +import com.swmansion.starknet.data.ContractAddressCalculator +import com.swmansion.starknet.data.types.Call +import com.swmansion.starknet.data.types.DeployAccountResponse +import com.swmansion.starknet.data.types.Felt +import com.swmansion.starknet.data.types.StarknetChainId +import com.swmansion.starknet.data.types.Uint256 +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 +import java.math.BigInteger + +class StarknetClient(rpcUrl: String) { + + private val ETH_ERC20_ADDRESS = "0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d" + private val ACCOUNTCLASSHASH = "0x061dac032f228abef9c6626f995015233097ae253a7f72d68552db02f2971b8f" + + private val provider = JsonRpcProvider(rpcUrl) + private val tag = "StarknetClient" + private lateinit var keystore: Keystore + + fun deployAccount(): Pair { + // Predefined values for account creation + keystore = Keystore() + val randomPrivateKey = StandardAccount.generatePrivateKey() + keystore.storeData(randomPrivateKey.value.toString()) // save the key generated + val data = keystore.retrieveData() // retrieve it to generate public key + val privateKey = BigInteger(data).toFelt + + val publicKey = StarknetCurve.getPublicKey(privateKey) + + Log.d(tag, "Private key: ${privateKey.hexString()}") + Log.d(tag,"Public key: ${publicKey.hexString()}") + + val signer = StarkCurveSigner(privateKey) + + val salt = Felt.ONE + + val calldata = listOf(publicKey) + + val accountContractClassHash = Felt.fromHex(ACCOUNTCLASSHASH) + + val address = ContractAddressCalculator.calculateAddressFromHash( + classHash = accountContractClassHash, + calldata = calldata, + salt = salt + ) + + val account = StandardAccount( + address = address, + signer = signer, + provider = provider, + chainId = StarknetChainId.SEPOLIA, + ) + + + // Fund address first with Eth + // how to approach this, we need the user to fund the address with eth + + val payload = account.signDeployAccountV1( + classHash = accountContractClassHash, + calldata = listOf(publicKey), + salt = salt, + // 10*fee from estimate deploy account fee + maxFee = Felt.fromHex("0x219d2e16ea"), + ) + + val res: DeployAccountResponse = provider.deployAccount(payload).send() + Log.d(tag, "Account deployed successfully: $res") + + val deployedAddress = res.address?.hexString() ?: "" + return Pair(privateKey.hexString(), deployedAddress) + } + + suspend fun getBalance(accountAddress: Felt): String { + // Create a call to Starknet ERC-20 ETH contract + val call = Call( + contractAddress = ETH_ERC20_ADDRESS.toFelt, + entrypoint = "balanceOf", + calldata = listOf(accountAddress), // calldata is List + ) + + // Create a Request object which has to be executed in synchronous or asynchronous way + val request = provider.callContract(call) + + // Execute a Request asynchronously and handle potential errors + return try { + val future = request.sendAsync() + val response = future.await() // Await the completion without blocking + + // Validate response size + require(response.size >= 2) { "Response size is less than 2; cannot construct Uint256" } + + // Output value's type is UInt256 and is represented by two Felt values + val balance = Uint256( + low = response[0], + high = response[1], + ) + + convertToTokenBalance(balance) + } catch (e: Exception) { + // Handle or log the exception as needed + throw RuntimeException("Failed to fetch balance $e", e) + } + } + + + suspend fun transferFunds(account: Account, toAddress: Felt, amount: Uint256): Felt { + 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 + } + + private fun convertToTokenBalance(balance: Uint256): String { + // Convert the Uint256 to BigInteger for easier manipulation + val bigIntBalance = balance.value + + // Use fixed divisor for 18 decimals (10^18) + val divisor = BigInteger.TEN.pow(18) + + // Divide the balance by the divisor to get the whole number part + val whole = bigIntBalance.divide(divisor) + + // Get the fractional part by taking the remainder + val fractional = bigIntBalance.remainder(divisor) + + // Convert fractional to string and pad with leading zeros if necessary + var fractionalStr = fractional.toString() + // Pad with leading zeros to match 18 decimal places + while (fractionalStr.length < 18) { + fractionalStr = "0$fractionalStr" + } + + // Trim trailing zeros from fractional part + fractionalStr = fractionalStr.trimEnd('0') + + // Construct the final string + return if (fractionalStr.isEmpty()) { + whole.toString() + } else { + "$whole.$fractionalStr" + } + } + + companion object { + fun toUint256Amount(amount: String, decimals: Int = 18): Uint256 { + require(decimals >= 0) { "Decimals must be non-negative" } + + return try { + val bigDecimalAmount = BigDecimal(amount) + require(bigDecimalAmount >= BigDecimal.ZERO) { "Amount must be non-negative" } + + // Check if decimal places don't exceed the specified decimals + val scale = bigDecimalAmount.scale() + require(scale <= decimals) { "Too many decimal places. Maximum allowed: $decimals" } + + val scaledAmount = bigDecimalAmount.multiply(BigDecimal.TEN.pow(decimals)).toBigInteger() + val hexValue = "0x" + scaledAmount.toString(16) + Uint256.fromHex(hexValue) + } catch (e: NumberFormatException) { + throw IllegalArgumentException("Invalid amount format: $amount") + } + } + } +} diff --git a/wallet-sdk/app/src/main/res/layout/activity_main.xml b/wallet-sdk/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..9affce0 --- /dev/null +++ b/wallet-sdk/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/wallet-sdk/gradle/libs.versions.toml b/wallet-sdk/gradle/libs.versions.toml index f31eb0e..80bf62c 100644 --- a/wallet-sdk/gradle/libs.versions.toml +++ b/wallet-sdk/gradle/libs.versions.toml @@ -1,21 +1,34 @@ [versions] agp = "8.5.2" -kotlin = "1.9.0" +kotlin = "2.0.0" coreKtx = "1.15.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" +kotlinxCoroutinesTest = "1.7.2" material = "1.12.0" +roomRuntime = "2.6.1" +securityCryptoVersion = "1.1.0-alpha06" +activity = "1.9.3" +constraintlayout = "2.1.4" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomRuntime" } +androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomRuntime" } +androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomRuntime" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +androidx-security-crypto-v110alpha06 = { module = "androidx.security:security-crypto", version.ref = "securityCryptoVersion" } +androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" } +androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }