Initial commit: Pezkuwi Wallet Android

Complete rebrand of Nova Wallet for Pezkuwichain ecosystem.

## Features
- Full Pezkuwichain support (HEZ & PEZ tokens)
- Polkadot ecosystem compatibility
- Staking, Governance, DeFi, NFTs
- XCM cross-chain transfers
- Hardware wallet support (Ledger, Polkadot Vault)
- WalletConnect v2
- Push notifications

## Languages
- English, Turkish, Kurmanci (Kurdish), Spanish, French, German, Russian, Japanese, Chinese, Korean, Portuguese, Vietnamese

Based on Nova Wallet by Novasama Technologies GmbH
© Dijital Kurdistan Tech Institute 2026
This commit is contained in:
2026-01-23 01:31:12 +03:00
commit 31c8c5995f
7621 changed files with 425838 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+75
View File
@@ -0,0 +1,75 @@
apply plugin: 'kotlin-parcelize'
apply from: '../tests.gradle'
apply from: "../scripts/secrets.gradle"
android {
namespace 'io.novafoundation.nova.feature_external_sign_impl'
defaultConfig {
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
packagingOptions {
resources.excludes.add("META-INF/NOTICE.md")
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':core-db')
implementation project(':common')
implementation project(':feature-account-api')
implementation project(':feature-wallet-api')
implementation project(':feature-external-sign-api')
implementation project(':feature-currency-api')
implementation project(':runtime')
implementation kotlinDep
implementation androidDep
implementation materialDep
implementation constraintDep
implementation shimmerDep
implementation coroutinesDep
implementation gsonDep
implementation daggerDep
ksp daggerCompiler
implementation lifecycleDep
ksp lifecycleCompiler
implementation viewModelKtxDep
implementation liveDataKtxDep
implementation lifeCycleKtxDep
implementation retrofitDep
implementation web3jDep
implementation coroutinesFutureDep
implementation walletConnectCoreDep, withoutTransitiveAndroidX
implementation walletConnectWalletDep, withoutTransitiveAndroidX
testImplementation jUnitDep
testImplementation mockitoDep
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_external_sign_impl
import io.novafoundation.nova.common.navigation.ReturnableRouter
interface ExternalSignRouter : ReturnableRouter {
fun openExtrinsicDetails(extrinsicContent: String)
}
@@ -0,0 +1,203 @@
package io.novafoundation.nova.feature_external_sign_impl.data.evmApi
import io.novafoundation.nova.common.utils.toEcdsaSignatureData
import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProvider
import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource.UnknownChainOptions
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.findEvmCallApi
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.Signer
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import okhttp3.OkHttpClient
import org.web3j.crypto.RawTransaction
import org.web3j.crypto.Sign.SignatureData
import org.web3j.crypto.TransactionEncoder
import org.web3j.protocol.Web3j
import org.web3j.protocol.core.DefaultBlockParameterName
import org.web3j.protocol.core.methods.request.Transaction
import org.web3j.protocol.http.HttpService
import org.web3j.rlp.RlpEncoder
import org.web3j.rlp.RlpList
import org.web3j.tx.RawTransactionManager
import java.math.BigInteger
interface EvmApi {
suspend fun formTransaction(
fromAddress: String,
toAddress: String,
data: String?,
value: BigInteger?,
nonce: BigInteger? = null,
gasLimit: BigInteger? = null,
gasPrice: BigInteger? = null,
): RawTransaction
/**
* @return hash of submitted transaction
*/
suspend fun sendTransaction(
transaction: RawTransaction,
signer: Signer,
accountId: AccountId,
ethereumChainId: Long,
): String
/**
* @return signed transaction, ready to be send by eth_sendRawTransaction
*/
suspend fun signTransaction(
transaction: RawTransaction,
signer: Signer,
accountId: AccountId,
ethereumChainId: Long,
): String
suspend fun getAccountBalance(address: String): BigInteger
fun shutdown()
}
class EvmApiFactory(
private val okHttpClient: OkHttpClient,
private val chainRegistry: ChainRegistry,
private val gasPriceProviderFactory: GasPriceProviderFactory,
) {
suspend fun create(chainSource: EvmChainSource): EvmApi? {
val knownWeb3jApi = chainRegistry.findEvmCallApi(chainSource.evmChainId)
val unknownChainOptions = chainSource.unknownChainOptions
return when {
knownWeb3jApi != null -> {
Web3JEvmApi(
web3 = knownWeb3jApi,
shouldShutdown = false,
gasPriceProvider = gasPriceProviderFactory.create(knownWeb3jApi)
)
}
unknownChainOptions is UnknownChainOptions.WithFallBack -> {
val web3Api = createWeb3j(unknownChainOptions.evmChain.rpcUrl)
Web3JEvmApi(
web3 = web3Api,
shouldShutdown = true,
gasPriceProvider = gasPriceProviderFactory.create(web3Api)
)
}
else -> null
}
}
private fun createWeb3j(url: String): Web3j {
return Web3j.build(HttpService(url, okHttpClient))
}
}
private class Web3JEvmApi(
private val web3: Web3j,
private val shouldShutdown: Boolean,
private val gasPriceProvider: GasPriceProvider,
) : EvmApi {
override suspend fun formTransaction(
fromAddress: String,
toAddress: String,
data: String?,
value: BigInteger?,
nonce: BigInteger?,
gasLimit: BigInteger?,
gasPrice: BigInteger?,
): RawTransaction {
val finalNonce = nonce ?: getNonce(fromAddress)
val finalGasPrice = gasPrice ?: gasPriceProvider.getGasPrice()
val dataOrDefault = data.orEmpty()
val finalGasLimit = gasLimit ?: run {
val forFeeEstimatesTx = Transaction.createFunctionCallTransaction(
fromAddress,
finalNonce,
null,
null,
toAddress,
value,
dataOrDefault
)
estimateGasLimit(forFeeEstimatesTx)
}
return RawTransaction.createTransaction(
finalNonce,
finalGasPrice,
finalGasLimit,
toAddress,
value,
dataOrDefault
)
}
/**
* Ethereum signing is adopted from [TransactionEncoder.signMessage] and [RawTransactionManager.sign]
*/
override suspend fun sendTransaction(
transaction: RawTransaction,
signer: Signer,
accountId: AccountId,
ethereumChainId: Long,
): String {
val signedRawTransaction = signTransaction(transaction, signer, accountId, ethereumChainId)
return sendTransaction(signedRawTransaction)
}
override suspend fun signTransaction(
transaction: RawTransaction,
signer: Signer,
accountId: AccountId,
ethereumChainId: Long
): String {
val encodedTx = TransactionEncoder.encode(transaction, ethereumChainId)
val signerPayload = SignerPayloadRaw(encodedTx, accountId)
val signatureData = signer.signRaw(signerPayload).toEcdsaSignatureData()
val eip155SignatureData: SignatureData = TransactionEncoder.createEip155SignatureData(signatureData, ethereumChainId)
return transaction.encodeWith(eip155SignatureData).toHexString(withPrefix = true)
}
override suspend fun getAccountBalance(address: String): BigInteger {
return web3.ethGetBalance(address, DefaultBlockParameterName.LATEST).sendSuspend().balance
}
override fun shutdown() {
if (shouldShutdown) web3.shutdown()
}
private suspend fun sendTransaction(transactionData: String): String {
return web3.ethSendRawTransaction(transactionData).sendSuspend().transactionHash
}
private suspend fun getNonce(address: String): BigInteger {
return web3.ethGetTransactionCount(address, DefaultBlockParameterName.PENDING)
.sendSuspend()
.transactionCount
}
private suspend fun estimateGasLimit(tx: Transaction): BigInteger {
return web3.ethEstimateGas(tx).sendSuspend().amountUsed
}
private fun RawTransaction.encodeWith(signatureData: SignatureData): ByteArray {
val values = TransactionEncoder.asRlpValues(this, signatureData)
val rlpList = RlpList(values)
return RlpEncoder.encode(rlpList)
}
}
@@ -0,0 +1,52 @@
package io.novafoundation.nova.feature_external_sign_impl.di
import dagger.BindsInstance
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter
import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.di.ExternalExtrinsicDetailsComponent
import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.di.ExternalSignComponent
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
@Component(
dependencies = [
ExternalSignFeatureDependencies::class
],
modules = [
ExternalSignFeatureModule::class
]
)
@FeatureScope
interface ExternalSignFeatureComponent : ExternalSignFeatureApi {
fun signExtrinsicComponentFactory(): ExternalSignComponent.Factory
fun extrinsicDetailsComponentFactory(): ExternalExtrinsicDetailsComponent.Factory
@Component.Factory
interface Factory {
fun create(
@BindsInstance router: ExternalSignRouter,
@BindsInstance signCommunicator: ExternalSignCommunicator,
deps: ExternalSignFeatureDependencies
): ExternalSignFeatureComponent
}
@Component(
dependencies = [
CommonApi::class,
AccountFeatureApi::class,
WalletFeatureApi::class,
RuntimeApi::class,
CurrencyFeatureApi::class,
]
)
interface ExternalSignFeatureDependenciesComponent : ExternalSignFeatureDependencies
}
@@ -0,0 +1,76 @@
package io.novafoundation.nova.feature_external_sign_impl.di
import coil.ImageLoader
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.WalletRepository
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.runtime.di.ExtrinsicSerialization
import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory
import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import okhttp3.OkHttpClient
interface ExternalSignFeatureDependencies {
val amountFormatter: AmountFormatter
fun currencyRepository(): CurrencyRepository
fun accountRepository(): AccountRepository
fun resourceManager(): ResourceManager
fun selectedAccountUseCase(): SelectedAccountUseCase
fun addressIconGenerator(): AddressIconGenerator
fun chainRegistry(): ChainRegistry
fun imageLoader(): ImageLoader
fun extrinsicService(): ExtrinsicService
fun tokenRepository(): TokenRepository
fun secretStoreV2(): SecretStoreV2
@ExtrinsicSerialization
fun extrinsicGson(): Gson
val feeLoaderMixinFactory: FeeLoaderMixinV2.Factory
val actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
val walletUiUseCase: WalletUiUseCase
val okHttpClient: OkHttpClient
val walletRepository: WalletRepository
val validationExecutor: ValidationExecutor
val signerProvider: SignerProvider
val gasPriceProviderFactory: GasPriceProviderFactory
val rpcCalls: RpcCalls
val metadataShortenerService: MetadataShortenerService
val signingContextFactory: SigningContext.Factory
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_external_sign_impl.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.feature_account_api.di.AccountFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter
import io.novafoundation.nova.feature_wallet_api.di.WalletFeatureApi
import io.novafoundation.nova.runtime.di.RuntimeApi
import javax.inject.Inject
@ApplicationScope
class ExternalSignFeatureHolder @Inject constructor(
featureContainer: FeatureContainer,
private val router: ExternalSignRouter,
private val signCommunicator: ExternalSignCommunicator,
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val deps = DaggerExternalSignFeatureComponent_ExternalSignFeatureDependenciesComponent.builder()
.commonApi(commonApi())
.accountFeatureApi(getFeature(AccountFeatureApi::class.java))
.walletFeatureApi(getFeature(WalletFeatureApi::class.java))
.runtimeApi(getFeature(RuntimeApi::class.java))
.currencyFeatureApi(getFeature(CurrencyFeatureApi::class.java))
.build()
return DaggerExternalSignFeatureComponent.factory()
.create(router, signCommunicator, deps)
}
}
@@ -0,0 +1,17 @@
package io.novafoundation.nova.feature_external_sign_impl.di
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser
import io.novafoundation.nova.feature_external_sign_impl.di.modules.sign.EvmSignModule
import io.novafoundation.nova.feature_external_sign_impl.di.modules.sign.PolkadotSignModule
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm.RealEvmTypedMessageParser
@Module(includes = [EvmSignModule::class, PolkadotSignModule::class])
class ExternalSignFeatureModule {
@Provides
@FeatureScope
fun provideEvmTypedMessageParser(): EvmTypedMessageParser = RealEvmTypedMessageParser()
}
@@ -0,0 +1,53 @@
package io.novafoundation.nova.feature_external_sign_impl.di.modules.sign
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_external_sign_impl.data.evmApi.EvmApiFactory
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm.EvmSignInteractorFactory
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.runtime.di.ExtrinsicSerialization
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import okhttp3.OkHttpClient
@Module
class EvmSignModule {
@Provides
@FeatureScope
fun provideEthereumApiFactory(
okHttpClient: OkHttpClient,
chainRegistry: ChainRegistry,
gasPriceProviderFactory: GasPriceProviderFactory,
): EvmApiFactory {
return EvmApiFactory(okHttpClient, chainRegistry, gasPriceProviderFactory)
}
@Provides
@FeatureScope
fun provideSignInteractorFactory(
chainRegistry: ChainRegistry,
accountRepository: AccountRepository,
tokenRepository: TokenRepository,
@ExtrinsicSerialization extrinsicGson: Gson,
addressIconGenerator: AddressIconGenerator,
evmApiFactory: EvmApiFactory,
signerProvider: SignerProvider,
currencyRepository: CurrencyRepository
) = EvmSignInteractorFactory(
chainRegistry = chainRegistry,
accountRepository = accountRepository,
signerProvider = signerProvider,
tokenRepository = tokenRepository,
currencyRepository = currencyRepository,
extrinsicGson = extrinsicGson,
addressIconGenerator = addressIconGenerator,
evmApiFactory = evmApiFactory
)
}
@@ -0,0 +1,54 @@
package io.novafoundation.nova.feature_external_sign_impl.di.modules.sign
import com.google.gson.Gson
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_external_sign_impl.di.modules.sign.PolkadotSignModule.BindsModule
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.PolkadotSignInteractorFactory
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.RealSignBytesChainResolver
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.SignBytesChainResolver
import io.novafoundation.nova.runtime.di.ExtrinsicSerialization
import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [BindsModule::class])
class PolkadotSignModule {
@Module
interface BindsModule {
@Binds
fun bindSignBytesResolver(real: RealSignBytesChainResolver): SignBytesChainResolver
}
@Provides
@FeatureScope
fun provideSignInteractorFactory(
extrinsicService: ExtrinsicService,
chainRegistry: ChainRegistry,
accountRepository: AccountRepository,
@ExtrinsicSerialization extrinsicGson: Gson,
addressIconGenerator: AddressIconGenerator,
signerProvider: SignerProvider,
metadataShortenerService: MetadataShortenerService,
signingContextFactory: SigningContext.Factory,
signBytesChainResolver: SignBytesChainResolver
) = PolkadotSignInteractorFactory(
extrinsicService = extrinsicService,
chainRegistry = chainRegistry,
accountRepository = accountRepository,
extrinsicGson = extrinsicGson,
addressIconGenerator = addressIconGenerator,
signerProvider = signerProvider,
metadataShortenerService = metadataShortenerService,
signingContextFactory = signingContextFactory,
signBytesChainResolver = signBytesChainResolver
)
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
abstract class BaseExternalSignInteractor(
private val accountRepository: AccountRepository,
private val wallet: ExternalSignWallet,
private val signerProvider: SignerProvider,
) : ExternalSignInteractor {
protected suspend fun resolveWalletSigner(): NovaSigner {
val metaAccount = resolveMetaAccount()
return signerProvider.rootSignerFor(metaAccount)
}
protected suspend fun resolveMetaAccount(): MetaAccount {
return when (wallet) {
ExternalSignWallet.Current -> accountRepository.getSelectedMetaAccount()
is ExternalSignWallet.WithId -> accountRepository.getMetaAccount(wallet.metaId)
}
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign
import io.novasama.substrate_sdk_android.extensions.fromHex
fun String.tryConvertHexToUtf8(): String {
return runCatching { fromHex().decodeToString(throwOnInvalidSequence = true) }
.getOrDefault(this)
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import kotlinx.coroutines.flow.Flow
interface ExternalSignInteractor {
sealed class Error : Throwable() {
class UnsupportedChain(val chainId: String) : Error()
}
val validationSystem: ConfirmDAppOperationValidationSystem
suspend fun createAccountAddressModel(): AddressModel
suspend fun chainUi(): Result<ChainUi?>
fun utilityAssetFlow(): Flow<Chain.Asset>?
suspend fun calculateFee(): Fee?
suspend fun performOperation(upToDateFee: Fee?): ExternalSignCommunicator.Response?
suspend fun readableOperationContent(): String
suspend fun shutdown() {}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_wallet_api.domain.validation.FeeChangeDetectedFailure
sealed class ConfirmDAppOperationValidationFailure {
class FeeSpikeDetected(override val payload: FeeChangeDetectedFailure.Payload<Fee>) :
ConfirmDAppOperationValidationFailure(),
FeeChangeDetectedFailure<Fee>
}
data class ConfirmDAppOperationValidationPayload(
val fee: Fee?
)
typealias ConfirmDAppOperationValidationSystem = ValidationSystem<ConfirmDAppOperationValidationPayload, ConfirmDAppOperationValidationFailure>
@@ -0,0 +1,342 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm
import android.util.Log
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.asHexString
import io.novafoundation.nova.common.utils.castOrNull
import io.novafoundation.nova.common.utils.decodeEvmQuantity
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.common.utils.lazyAsync
import io.novafoundation.nova.common.utils.parseArbitraryObject
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.common.validation.ValidationSystem
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.data.model.EvmFee
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.account.icon.createAccountAddressModel
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_currency_api.domain.interfaces.CurrencyRepository
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.failedSigningIfNotCancelled
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChain
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmChainSource
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmPersonalSignMessage
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload.ConfirmTx
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload.PersonalSign
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmSignPayload.SignTypedMessage
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTransaction
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage
import io.novafoundation.nova.feature_external_sign_impl.data.evmApi.EvmApi
import io.novafoundation.nova.feature_external_sign_impl.data.evmApi.EvmApiFactory
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.BaseExternalSignInteractor
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationFailure
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationSystem
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.tryConvertHexToUtf8
import io.novafoundation.nova.feature_wallet_api.domain.interfaces.TokenRepository
import io.novafoundation.nova.feature_wallet_api.domain.model.Token
import io.novafoundation.nova.feature_wallet_api.domain.validation.checkForFeeChanges
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.findChain
import io.novafoundation.nova.runtime.multiNetwork.findEvmChain
import io.novasama.substrate_sdk_android.extensions.asEthereumAddress
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.toAccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import org.web3j.crypto.RawTransaction
import org.web3j.crypto.TransactionDecoder
class EvmSignInteractorFactory(
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository,
private val currencyRepository: CurrencyRepository,
private val extrinsicGson: Gson,
private val addressIconGenerator: AddressIconGenerator,
private val evmApiFactory: EvmApiFactory,
private val signerProvider: SignerProvider
) {
fun create(request: ExternalSignRequest.Evm, wallet: ExternalSignWallet) = EvmSignInteractor(
chainRegistry = chainRegistry,
tokenRepository = tokenRepository,
currencyRepository = currencyRepository,
extrinsicGson = extrinsicGson,
addressIconGenerator = addressIconGenerator,
request = request,
evmApiFactory = evmApiFactory,
accountRepository = accountRepository,
signerProvider = signerProvider,
wallet = wallet
)
}
class EvmSignInteractor(
private val evmApiFactory: EvmApiFactory,
private val request: ExternalSignRequest.Evm,
private val addressIconGenerator: AddressIconGenerator,
private val tokenRepository: TokenRepository,
private val currencyRepository: CurrencyRepository,
private val chainRegistry: ChainRegistry,
private val extrinsicGson: Gson,
private val signerProvider: SignerProvider,
accountRepository: AccountRepository,
wallet: ExternalSignWallet,
) : BaseExternalSignInteractor(accountRepository, wallet, signerProvider),
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val mostRecentFormedTx = singleReplaySharedFlow<RawTransaction>()
private val payload = request.payload
@OptIn(DelicateCoroutinesApi::class)
private val ethereumApi by GlobalScope.lazyAsync {
payload.castOrNull<ConfirmTx>()?.chainSource?.let { chainSource ->
evmApiFactory.create(chainSource)
}
}
override val validationSystem: ConfirmDAppOperationValidationSystem = ValidationSystem {
if (payload is ConfirmTx) {
checkForFeeChanges(
calculateFee = { calculateFee()!! },
currentFee = { it.fee },
chainAsset = { it.fee!!.asset },
error = ConfirmDAppOperationValidationFailure::FeeSpikeDetected
)
}
}
override suspend fun createAccountAddressModel(): AddressModel = withContext(Dispatchers.Default) {
val address = request.payload.originAddress
val someEthereumChain = chainRegistry.findChain { it.isEthereumBased }!! // always have at least one ethereum chain in the app
addressIconGenerator.createAccountAddressModel(someEthereumChain, address)
}
override suspend fun chainUi(): Result<ChainUi?> = withContext(Dispatchers.Default) {
runCatching {
if (payload is ConfirmTx) {
chainRegistry.findEvmChain(payload.chainSource.evmChainId)?.let(::mapChainToUi)
?: payload.chainSource.fallbackChain?.let(::mapEvmChainToUi)
?: throw ExternalSignInteractor.Error.UnsupportedChain(payload.chainSource.evmChainId.toString())
} else {
null
}
}
}
override fun utilityAssetFlow(): Flow<Chain.Asset>? {
if (payload !is ConfirmTx) return null
return flowOf {
chainRegistry.findEvmChain(payload.chainSource.evmChainId)?.utilityAsset
?: createAssetFrom(payload.chainSource.unknownChainOptions)
}.filterNotNull()
}
override suspend fun calculateFee(): Fee? = withContext(Dispatchers.Default) {
if (payload !is ConfirmTx) return@withContext null
resolveWalletSigner()
val api = ethereumApi() ?: return@withContext null
val chain = chainRegistry.findEvmChain(payload.chainSource.evmChainId)
// Commission asset for evm
val chainAsset = chain?.utilityAsset ?: createAssetFrom(payload.chainSource.unknownChainOptions) ?: return@withContext null
val tx = api.formTransaction(payload.transaction, feeOverride = null)
mostRecentFormedTx.emit(tx)
tx.fee(chainAsset)
}
override suspend fun performOperation(upToDateFee: Fee?): ExternalSignCommunicator.Response? = withContext(Dispatchers.Default) {
runCatching {
when (payload) {
is ConfirmTx -> confirmTx(payload.transaction, upToDateFee, payload.chainSource.evmChainId.toLong(), payload.action)
is SignTypedMessage -> signTypedMessage(payload.message)
is PersonalSign -> personalSign(payload.message)
}
}.getOrElse { error ->
Log.e(LOG_TAG, "Failed to sign evm tx", error)
error.failedSigningIfNotCancelled(request.id)
}
}
override suspend fun readableOperationContent(): String = withContext(Dispatchers.Default) {
when (payload) {
is ConfirmTx -> extrinsicGson.toJson(mostRecentFormedTx.first())
is SignTypedMessage -> signTypedMessageReadableContent(payload)
is PersonalSign -> personalSignReadableContent(payload)
}
}
override suspend fun shutdown() {
ethereumApi()?.shutdown()
}
private suspend fun confirmTx(basedOn: EvmTransaction, upToDateFee: Fee?, evmChainId: Long, action: ConfirmTx.Action): ExternalSignCommunicator.Response {
val api = requireNotNull(ethereumApi())
val tx = api.formTransaction(basedOn, upToDateFee)
val originAccountId = originAccountId()
val signer = resolveWalletSigner()
return when (action) {
ConfirmTx.Action.SIGN -> {
val signedTx = api.signTransaction(tx, signer, originAccountId, evmChainId)
ExternalSignCommunicator.Response.Signed(request.id, signedTx)
}
ConfirmTx.Action.SEND -> {
val txHash = api.sendTransaction(tx, signer, originAccountId, evmChainId)
ExternalSignCommunicator.Response.Sent(request.id, txHash)
}
}
}
private suspend fun signTypedMessage(message: EvmTypedMessage): ExternalSignCommunicator.Response.Signed {
val signature = signMessage(message.data.fromHex())
return ExternalSignCommunicator.Response.Signed(request.id, signature)
}
private suspend fun personalSign(message: EvmPersonalSignMessage): ExternalSignCommunicator.Response.Signed {
val personalSignMessage = message.data.fromHex().asEthereumPersonalSignMessage()
val payload = SignerPayloadRaw(personalSignMessage, originAccountId(), skipMessageHashing = true)
val signature = resolveWalletSigner().signRaw(payload).asHexString()
return ExternalSignCommunicator.Response.Signed(request.id, signature)
}
private suspend fun signMessage(message: ByteArray): String {
val signerPayload = SignerPayloadRaw(message, originAccountId(), skipMessageHashing = true)
return resolveWalletSigner().signRaw(signerPayload).asHexString()
}
private fun personalSignReadableContent(payload: PersonalSign): String {
val data = payload.message.data
return data.tryConvertHexToUtf8()
}
private fun signTypedMessageReadableContent(payload: SignTypedMessage): String {
return runCatching {
val parsedRaw = extrinsicGson.parseArbitraryObject(payload.message.raw!!)
val wrapped = mapOf(
"data" to payload.message.data,
"raw" to parsedRaw
)
extrinsicGson.toJson(wrapped)
}.getOrElse {
extrinsicGson.toJson(payload.message)
}
}
private fun mapEvmChainToUi(metamaskChain: EvmChain): ChainUi {
return ChainUi(
id = metamaskChain.chainId,
name = metamaskChain.chainName,
icon = metamaskChain.iconUrl
)
}
private suspend fun EvmApi.formTransaction(basedOn: EvmTransaction, feeOverride: Fee?): RawTransaction {
return when (basedOn) {
is EvmTransaction.Raw -> TransactionDecoder.decode(basedOn.rawContent)
is EvmTransaction.Struct -> {
val evmFee = feeOverride.castOrNull<EvmFee>()
formTransaction(
fromAddress = basedOn.from,
toAddress = basedOn.to,
data = basedOn.data,
value = basedOn.value?.decodeEvmQuantity(),
nonce = basedOn.nonce?.decodeEvmQuantity(),
gasLimit = evmFee?.gasLimit ?: basedOn.gas?.decodeEvmQuantity(),
gasPrice = evmFee?.gasPrice ?: basedOn.gasPrice?.decodeEvmQuantity()
)
}
}
}
private suspend fun createTokenFrom(unknownChainOptions: EvmChainSource.UnknownChainOptions): Token? {
if (unknownChainOptions !is EvmChainSource.UnknownChainOptions.WithFallBack) return null
val asset = createAssetFrom(unknownChainOptions) ?: return null
return Token(
configuration = asset,
coinRate = null,
currency = currencyRepository.getSelectedCurrency()
)
}
private fun createAssetFrom(unknownChainOptions: EvmChainSource.UnknownChainOptions): Chain.Asset? {
if (unknownChainOptions !is EvmChainSource.UnknownChainOptions.WithFallBack) return null
val evmChain = unknownChainOptions.evmChain
val chainCurrency = evmChain.nativeCurrency
return Chain.Asset(
icon = null,
id = 0,
priceId = null,
chainId = evmChain.chainId,
symbol = chainCurrency.symbol,
precision = chainCurrency.decimals,
buyProviders = emptyMap(),
sellProviders = emptyMap(),
staking = emptyList(),
type = Chain.Asset.Type.EvmNative,
name = chainCurrency.name,
source = Chain.Asset.Source.ERC20,
enabled = true
)
}
private fun RawTransaction.fee(chainAsset: Chain.Asset): Fee = EvmFee(
gasLimit = gasLimit,
gasPrice = gasPrice,
submissionOrigin = submissionOrigin(),
chainAsset
)
private fun submissionOrigin() = SubmissionOrigin.singleOrigin(originAccountId())
private fun originAccountId() = payload.originAddress.asEthereumAddress().toAccountId().value
private val EvmChainSource.fallbackChain: EvmChain?
get() = when (val options = unknownChainOptions) {
EvmChainSource.UnknownChainOptions.MustBeKnown -> null
is EvmChainSource.UnknownChainOptions.WithFallBack -> options.evmChain
}
}
@@ -0,0 +1,24 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm
import org.web3j.crypto.Hash
import org.web3j.crypto.Sign
private const val MESSAGE_PREFIX = "\u0019Ethereum Signed Message:\n"
private fun getEthereumMessagePrefix(messageLength: Int): ByteArray {
return (MESSAGE_PREFIX + messageLength.toString()).encodeToByteArray()
}
/**
* Adopted from [Sign.getEthereumMessageHash] since the former method is package-private and cannot be directly accessed by the calling code
*/
fun ByteArray.asEthereumPersonalSignMessage(): ByteArray {
val prefix = getEthereumMessagePrefix(size)
val result = ByteArray(prefix.size + size)
System.arraycopy(prefix, 0, result, 0, prefix.size)
System.arraycopy(this, 0, result, prefix.size, size)
return Hash.sha3(result)
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm
import io.novafoundation.nova.feature_external_sign_api.domain.sign.evm.EvmTypedMessageParser
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.evm.EvmTypedMessage
import io.novasama.substrate_sdk_android.extensions.toHexString
import org.web3j.crypto.StructuredDataEncoder
internal class RealEvmTypedMessageParser : EvmTypedMessageParser {
override fun parseEvmTypedMessage(message: String): EvmTypedMessage {
val encoder = StructuredDataEncoder(message)
return EvmTypedMessage(
data = encoder.hashStructuredData().toHexString(withPrefix = true),
raw = message
)
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import java.math.BigInteger
data class DAppParsedExtrinsic(
val address: String,
val nonce: BigInteger,
val specVersion: Int,
val transactionVersion: Int,
val genesisHash: ByteArray,
val era: Era,
val blockHash: ByteArray,
val tip: BigInteger,
val metadataHash: ByteArray?,
val call: GenericCall.Instance,
val assetId: Any?,
)
@@ -0,0 +1,364 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot
import android.util.Log
import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.AddressModel
import io.novafoundation.nova.common.utils.asHexString
import io.novafoundation.nova.common.utils.bigIntegerFromHex
import io.novafoundation.nova.common.utils.convertToExternalCompatibleFormat
import io.novafoundation.nova.common.utils.endsWith
import io.novafoundation.nova.common.utils.intFromHex
import io.novafoundation.nova.common.utils.singleReplaySharedFlow
import io.novafoundation.nova.common.utils.startsWith
import io.novafoundation.nova.common.validation.EmptyValidationSystem
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.fee.types.assetHub.decodeCustomTxPaymentId
import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
import io.novafoundation.nova.feature_account_api.data.signer.SigningContext
import io.novafoundation.nova.feature_account_api.data.signer.SigningMode
import io.novafoundation.nova.feature_account_api.data.signer.setSignerData
import io.novafoundation.nova.feature_account_api.data.signer.signRaw
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.failedSigningIfNotCancelled
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.PolkadotSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.polkadot.maybeSignExtrinsic
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.BaseExternalSignInteractor
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationSystem
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.tryConvertHexToUtf8
import io.novafoundation.nova.runtime.ext.accountIdOf
import io.novafoundation.nova.runtime.ext.anyAddressToAccountId
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.extrinsic.CustomTransactionExtensions
import io.novafoundation.nova.runtime.extrinsic.extensions.ChargeAssetTxPayment.Companion.chargeAssetTxPayment
import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getChainOrNull
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.EraType
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode
import io.novasama.substrate_sdk_android.runtime.extrinsic.ExtrinsicVersion
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SendableExtrinsic
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.ChargeTransactionPayment
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckGenesis
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckMortality
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckSpecVersion
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckTxVersion
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHash
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.withContext
class PolkadotSignInteractorFactory(
private val extrinsicService: ExtrinsicService,
private val chainRegistry: ChainRegistry,
private val accountRepository: AccountRepository,
private val extrinsicGson: Gson,
private val addressIconGenerator: AddressIconGenerator,
private val metadataShortenerService: MetadataShortenerService,
private val signBytesChainResolver: SignBytesChainResolver,
private val signerProvider: SignerProvider,
private val signingContextFactory: SigningContext.Factory,
) {
fun create(request: ExternalSignRequest.Polkadot, wallet: ExternalSignWallet) = PolkadotExternalSignInteractor(
extrinsicService = extrinsicService,
chainRegistry = chainRegistry,
accountRepository = accountRepository,
extrinsicGson = extrinsicGson,
addressIconGenerator = addressIconGenerator,
request = request,
wallet = wallet,
signerProvider = signerProvider,
metadataShortenerService = metadataShortenerService,
signingContextFactory = signingContextFactory,
signBytesChainResolver = signBytesChainResolver
)
}
class PolkadotExternalSignInteractor(
private val extrinsicService: ExtrinsicService,
private val chainRegistry: ChainRegistry,
private val extrinsicGson: Gson,
private val addressIconGenerator: AddressIconGenerator,
private val request: ExternalSignRequest.Polkadot,
private val signerProvider: SignerProvider,
private val metadataShortenerService: MetadataShortenerService,
private val signingContextFactory: SigningContext.Factory,
private val signBytesChainResolver: SignBytesChainResolver,
wallet: ExternalSignWallet,
accountRepository: AccountRepository
) : BaseExternalSignInteractor(accountRepository, wallet, signerProvider) {
private val signPayload = request.payload
private val actualParsedExtrinsic = singleReplaySharedFlow<DAppParsedExtrinsic>()
override val validationSystem: ConfirmDAppOperationValidationSystem = EmptyValidationSystem()
override suspend fun createAccountAddressModel(): AddressModel {
val icon = addressIconGenerator.createAddressIcon(
accountId = signPayload.accountId(),
sizeInDp = AddressIconGenerator.SIZE_MEDIUM,
backgroundColorRes = AddressIconGenerator.BACKGROUND_TRANSPARENT
)
return AddressModel(signPayload.address, icon, name = null)
}
override suspend fun chainUi(): Result<ChainUi?> {
return runCatching {
signPayload.maybeSignExtrinsic()?.let {
mapChainToUi(it.chain())
}
}
}
override fun utilityAssetFlow(): Flow<Chain.Asset>? {
val chainId = signPayload.maybeSignExtrinsic()?.genesisHash ?: return null
return flow {
val chain = chainRegistry.getChainOrNull(chainId) ?: return@flow
emit(chain.utilityAsset)
}
}
override suspend fun performOperation(upToDateFee: Fee?): ExternalSignCommunicator.Response? = withContext(Dispatchers.Default) {
runCatching {
when (signPayload) {
is PolkadotSignPayload.Json -> signExtrinsic(signPayload)
is PolkadotSignPayload.Raw -> signBytes(signPayload)
}
}
.onFailure { Log.e("PolkadotExternalSignInteractor", "Failed to sign", it) }
.fold(
onSuccess = { signedResult ->
ExternalSignCommunicator.Response.Signed(request.id, signedResult.signature, signedResult.modifiedTransaction)
},
onFailure = { error ->
error.failedSigningIfNotCancelled(request.id)
}
)
}
override suspend fun readableOperationContent(): String = withContext(Dispatchers.Default) {
when (signPayload) {
is PolkadotSignPayload.Json -> readableExtrinsicContent()
is PolkadotSignPayload.Raw -> readableBytesContent(signPayload)
}
}
override suspend fun calculateFee(): Fee? = withContext(Dispatchers.Default) {
require(signPayload is PolkadotSignPayload.Json)
val chain = signPayload.chainOrNull() ?: return@withContext null
val signer = resolveWalletSigner()
val (extrinsic, _, parsedExtrinsic) = signPayload.analyzeAndSign(signer, SigningMode.FEE)
actualParsedExtrinsic.emit(parsedExtrinsic)
extrinsicService.estimateFee(chain, extrinsic.extrinsicHex, signer)
}
private fun readableBytesContent(signBytesPayload: PolkadotSignPayload.Raw): String {
return signBytesPayload.data.tryConvertHexToUtf8()
}
private suspend fun readableExtrinsicContent(): String {
return extrinsicGson.toJson(actualParsedExtrinsic.first())
}
private suspend fun signBytes(signBytesPayload: PolkadotSignPayload.Raw): SignedResult {
val accountId = signBytesPayload.address.anyAddressToAccountId()
val signer = resolveWalletSigner()
val payload = SignerPayloadRaw.fromUnsafeString(signBytesPayload.data, accountId)
val chainId = signBytesChainResolver.resolveChainId(signBytesPayload.address)
val signature = signer.signRaw(payload, chainId).signatureWrapper.convertToExternalCompatibleFormat()
return SignedResult(signature.asHexString(), modifiedTransaction = null)
}
private suspend fun signExtrinsic(extrinsicPayload: PolkadotSignPayload.Json): SignedResult {
val signer = resolveWalletSigner()
val (extrinsic, modifiedOriginal) = extrinsicPayload.analyzeAndSign(signer, SigningMode.SUBMISSION)
val modifiedTx = if (modifiedOriginal) extrinsic.extrinsicHex else null
return SignedResult(extrinsic.signatureHex, modifiedTx)
}
private suspend fun PolkadotSignPayload.Json.analyzeAndSign(
signer: NovaSigner,
signingMode: SigningMode
): ActualExtrinsic {
val chain = chain()
val runtime = chainRegistry.getRuntime(genesisHash)
val parsedExtrinsic = parseDAppExtrinsic(runtime, this)
val actualMetadataHash = actualMetadataHash(chain, signer)
val signingContext = signingContextFactory.default(chain)
val extrinsic = with(parsedExtrinsic) {
ExtrinsicBuilder(runtime, ExtrinsicVersion.V4, BatchMode.BATCH_ALL).apply {
setTransactionExtension(CheckMortality(era, blockHash))
setTransactionExtension(CheckGenesis(genesisHash))
setTransactionExtension(ChargeTransactionPayment(tip))
setTransactionExtension(CheckMetadataHash(actualMetadataHash.checkMetadataHash))
setTransactionExtension(CheckSpecVersion(specVersion))
setTransactionExtension(CheckTxVersion(transactionVersion))
call(parsedExtrinsic.call)
CustomTransactionExtensions.applyDefaultValues(builder = this)
applyCustomSignedExtensions(parsedExtrinsic)
signer.setSignerData(signingContext, signingMode)
}
}.buildExtrinsic()
val actualParsedExtrinsic = parsedExtrinsic.copy(
metadataHash = actualMetadataHash.checkMetadataHash.metadataHash
)
return ActualExtrinsic(
signedExtrinsic = extrinsic,
modifiedOriginal = actualMetadataHash.modifiedOriginal,
actualParsedExtrinsic = actualParsedExtrinsic
)
}
private fun SignerPayloadRaw.Companion.fromUnsafeString(data: String, signer: AccountId): SignerPayloadRaw {
val unsafeMessage = decodeSigningMessage(data)
val safeMessage = protectSigningMessage(unsafeMessage)
return SignerPayloadRaw(safeMessage, signer)
}
private fun decodeSigningMessage(data: String): ByteArray {
return kotlin.runCatching { data.fromHex() }.getOrElse { data.encodeToByteArray() }
}
private fun protectSigningMessage(message: ByteArray): ByteArray {
val prefix = "<Bytes>".encodeToByteArray()
val suffix = "</Bytes>".encodeToByteArray()
if (message.startsWith(prefix) && message.endsWith(suffix)) return message
return prefix + message + suffix
}
private suspend fun PolkadotSignPayload.Json.actualMetadataHash(chain: Chain, signer: NovaSigner): ActualMetadataHash {
// If a dapp haven't declared a permission to modify extrinsic - return whatever metadataHash present in payload
if (withSignedTransaction != true) {
return ActualMetadataHash(modifiedOriginal = false, hexHash = metadataHash)
}
// If a dapp have specified metadata hash explicitly - use it
if (metadataHash != null) {
return ActualMetadataHash(modifiedOriginal = false, hexHash = metadataHash)
}
// Else generate and use our own proof
val metadataProof = metadataShortenerService.generateMetadataProof(chain.id)
return ActualMetadataHash(modifiedOriginal = true, checkMetadataHash = metadataProof.checkMetadataHash)
}
private fun PolkadotSignPayload.Json.decodedCall(runtime: RuntimeSnapshot): GenericCall.Instance {
return GenericCall.fromHex(runtime, method)
}
private suspend fun PolkadotSignPayload.Json.chain(): Chain {
return chainRegistry.getChainOrNull(genesisHash) ?: throw ExternalSignInteractor.Error.UnsupportedChain(genesisHash)
}
private suspend fun PolkadotSignPayload.Json.chainOrNull(): Chain? {
return chainRegistry.getChainOrNull(genesisHash)
}
private fun parseDAppExtrinsic(runtime: RuntimeSnapshot, payloadJSON: PolkadotSignPayload.Json): DAppParsedExtrinsic {
return with(payloadJSON) {
DAppParsedExtrinsic(
address = address,
nonce = nonce.bigIntegerFromHex(),
specVersion = specVersion.intFromHex(),
transactionVersion = transactionVersion.intFromHex(),
genesisHash = genesisHash.fromHex(),
blockHash = blockHash.fromHex(),
era = EraType.fromHex(runtime, era),
tip = tip.bigIntegerFromHex(),
call = decodedCall(runtime),
metadataHash = metadataHash?.fromHex(),
assetId = payloadJSON.tryDecodeAssetId(runtime)
)
}
}
private suspend fun PolkadotSignPayload.accountId(): AccountId {
return when (this) {
is PolkadotSignPayload.Json -> {
val chain = chainOrNull()
chain?.accountIdOf(address) ?: address.anyAddressToAccountId()
}
is PolkadotSignPayload.Raw -> address.anyAddressToAccountId()
}
}
private class ActualMetadataHash(val modifiedOriginal: Boolean, val checkMetadataHash: CheckMetadataHashMode) {
constructor(modifiedOriginal: Boolean, hash: ByteArray?) : this(modifiedOriginal, CheckMetadataHashMode(hash))
constructor(modifiedOriginal: Boolean, hexHash: String?) : this(modifiedOriginal, hexHash?.fromHex())
}
private data class ActualExtrinsic(
val signedExtrinsic: SendableExtrinsic,
val modifiedOriginal: Boolean,
val actualParsedExtrinsic: DAppParsedExtrinsic
)
private data class SignedResult(val signature: String, val modifiedTransaction: String?)
private fun ExtrinsicBuilder.applyCustomSignedExtensions(parsedExtrinsic: DAppParsedExtrinsic): ExtrinsicBuilder {
parsedExtrinsic.assetId?.let { chargeAssetTxPayment(it) }
return this
}
private fun PolkadotSignPayload.Json.tryDecodeAssetId(runtime: RuntimeSnapshot): Any? {
return assetId?.let(runtime::decodeCustomTxPaymentId)
}
}
private fun CheckMetadataHashMode(hash: ByteArray?): CheckMetadataHashMode {
return if (hash != null) {
CheckMetadataHashMode.Enabled(hash)
} else {
CheckMetadataHashMode.Disabled
}
}
private val CheckMetadataHashMode.metadataHash: ByteArray?
get() = if (this is CheckMetadataHashMode.Enabled) hash else null
@@ -0,0 +1,44 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot
import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.runtime.ext.Geneses
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.findChainIds
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.addressPrefix
import javax.inject.Inject
interface SignBytesChainResolver {
suspend fun resolveChainId(address: String): ChainId?
}
@FeatureScope
class RealSignBytesChainResolver @Inject constructor(
private val chainRegistry: ChainRegistry,
) : SignBytesChainResolver {
override suspend fun resolveChainId(address: String): ChainId? {
return runCatching {
val ss58Prefix = address.addressPrefix()
detectChainIdFromSs58Prefix(ss58Prefix.toInt())
}.getOrNull()
}
private suspend fun detectChainIdFromSs58Prefix(prefix: Int): ChainId? {
val chains = chainRegistry.findChainIds { it.addressPrefix == prefix }
// This mapping is targeted to provide better detection for UAF and Polkadot Vault derivations (PV has Polkadot and Kusama derivations by default)
return when {
chains.isEmpty() -> null
Chain.Geneses.POLKADOT in chains -> Chain.Geneses.POLKADOT
Chain.Geneses.KUSAMA in chains -> Chain.Geneses.KUSAMA
chains.size == 1 -> chains.single()
else -> Chain.Geneses.POLKADOT
}
}
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails
import androidx.core.os.bundleOf
import io.novafoundation.nova.common.base.BaseBottomSheetFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi
import io.novafoundation.nova.feature_external_sign_impl.databinding.FragmentDappExtrinsicDetailsBinding
import io.novafoundation.nova.feature_external_sign_impl.di.ExternalSignFeatureComponent
class ExternalExtrinsicDetailsFragment : BaseBottomSheetFragment<ExternalExtrinsicDetailsViewModel, FragmentDappExtrinsicDetailsBinding>() {
companion object {
private const val PAYLOAD_KEY = "PAYLOAD_KEY"
fun getBundle(extrinsicContent: String) = bundleOf(PAYLOAD_KEY to extrinsicContent)
}
override fun createBinding() = FragmentDappExtrinsicDetailsBinding.inflate(layoutInflater)
override fun initViews() {
binder.signExtrinsicToolbar.setHomeButtonListener { viewModel.closeClicked() }
}
override fun inject() {
FeatureUtils.getFeature<ExternalSignFeatureComponent>(this, ExternalSignFeatureApi::class.java)
.extrinsicDetailsComponentFactory()
.create(this, argument(PAYLOAD_KEY))
.inject(this)
}
override fun subscribe(viewModel: ExternalExtrinsicDetailsViewModel) {
binder.extrinsicDetailsContent.text = viewModel.extrinsicContent
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter
class ExternalExtrinsicDetailsViewModel(
private val router: ExternalSignRouter,
val extrinsicContent: String
) : BaseViewModel() {
fun closeClicked() {
router.back()
}
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.ExternalExtrinsicDetailsFragment
@Subcomponent(
modules = [
ExternalExtrinsicDetailsModule::class
]
)
@ScreenScope
interface ExternalExtrinsicDetailsComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance extrinsicContent: String,
): ExternalExtrinsicDetailsComponent
}
fun inject(fragment: ExternalExtrinsicDetailsFragment)
}
@@ -0,0 +1,34 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter
import io.novafoundation.nova.feature_external_sign_impl.presentation.extrinsicDetails.ExternalExtrinsicDetailsViewModel
@Module(includes = [ViewModelModule::class])
class ExternalExtrinsicDetailsModule {
@Provides
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ExternalExtrinsicDetailsViewModel {
return ViewModelProvider(fragment, factory).get(ExternalExtrinsicDetailsViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(ExternalExtrinsicDetailsViewModel::class)
fun provideViewModel(
router: ExternalSignRouter,
extrinsicContent: String
): ViewModel {
return ExternalExtrinsicDetailsViewModel(
router = router,
extrinsicContent = extrinsicContent
)
}
}
@@ -0,0 +1,117 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic
import androidx.core.os.bundleOf
import coil.ImageLoader
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.base.blockBackPressing
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.mixin.impl.observeValidations
import io.novafoundation.nova.common.utils.makeGone
import io.novafoundation.nova.common.utils.postToSelf
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.view.dialog.errorDialog
import io.novafoundation.nova.common.view.setProgressState
import io.novafoundation.nova.common.view.shape.addRipple
import io.novafoundation.nova.common.view.shape.getBlockDrawable
import io.novafoundation.nova.common.view.showValueOrHide
import io.novafoundation.nova.feature_account_api.view.showWallet
import io.novafoundation.nova.feature_account_api.view.showAddress
import io.novafoundation.nova.feature_account_api.view.showChain
import io.novafoundation.nova.feature_external_sign_api.di.ExternalSignFeatureApi
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload
import io.novafoundation.nova.feature_external_sign_api.presentation.dapp.showDAppIcon
import io.novafoundation.nova.feature_external_sign_impl.R
import io.novafoundation.nova.feature_external_sign_impl.databinding.FragmentConfirmSignExtrinsicBinding
import io.novafoundation.nova.feature_external_sign_impl.di.ExternalSignFeatureComponent
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.setupFeeLoading
import io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
import javax.inject.Inject
private const val PAYLOAD_KEY = "DAppSignExtrinsicFragment.Payload"
class ExternalSignFragment : BaseFragment<ExternalSignViewModel, FragmentConfirmSignExtrinsicBinding>() {
companion object {
fun getBundle(payload: ExternalSignPayload) = bundleOf(PAYLOAD_KEY to payload)
}
override fun createBinding() = FragmentConfirmSignExtrinsicBinding.inflate(layoutInflater)
@Inject
lateinit var imageLoader: ImageLoader
override fun initViews() {
blockBackPressing()
binder.confirmDAppActionAllow.prepareForProgress(viewLifecycleOwner)
binder.confirmDAppActionAllow.setOnClickListener { viewModel.acceptClicked() }
binder.confirmDAppActionAllow.setText(R.string.common_confirm)
binder.confirmDAppActionReject.setOnClickListener { viewModel.rejectClicked() }
binder.confirmSignExtinsicDetails.setOnClickListener { viewModel.detailsClicked() }
binder.confirmSignExtinsicDetails.background = with(requireContext()) {
addRipple(getBlockDrawable())
}
}
override fun inject() {
FeatureUtils.getFeature<ExternalSignFeatureComponent>(this, ExternalSignFeatureApi::class.java)
.signExtrinsicComponentFactory()
.create(this, argument(PAYLOAD_KEY))
.inject(this)
}
@Suppress("UNCHECKED_CAST")
override fun subscribe(viewModel: ExternalSignViewModel) {
setupFeeLoading(viewModel, binder.confirmSignExtinsicFee)
observeValidations(viewModel)
viewModel.maybeChainUi.observe { chainUi ->
binder.confirmSignExtinsicNetwork.postToSelf {
if (chainUi != null) {
showChain(chainUi)
} else {
makeGone()
}
}
}
viewModel.requestedAccountModel.observe {
binder.confirmSignExtinsicAccount.postToSelf { showAddress(it) }
}
viewModel.walletUi.observe {
binder.confirmSignExtinsicWallet.postToSelf { showWallet(it) }
}
binder.confirmSignExtinsicIcon.showDAppIcon(viewModel.dAppInfo?.icon, imageLoader)
binder.confirmSignExtinsicDappUrl.showValueOrHide(viewModel.dAppInfo?.url)
viewModel.performingOperationInProgress.observe { operationInProgress ->
val actionsAllowed = !operationInProgress
binder.confirmDAppActionReject.isEnabled = actionsAllowed
binder.confirmDAppActionAllow.setProgressState(show = operationInProgress)
}
viewModel.confirmUnrecoverableError.awaitableActionLiveData.observeEvent {
errorDialog(
context = requireContext(),
onConfirm = { it.onSuccess(Unit) }
) {
setMessage(it.payload)
}
}
}
private fun setupFeeLoading(viewModel: ExternalSignViewModel, feeView: FeeView) {
val mixin = viewModel.originFeeMixin
feeView.setVisible(mixin != null)
mixin?.let { setupFeeLoading(it, feeView) }
}
}
@@ -0,0 +1,209 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.mixin.actionAwaitable.confirmingAction
import io.novafoundation.nova.common.mixin.api.Validatable
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.flowOf
import io.novafoundation.nova.common.validation.TransformedFailure
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.common.validation.ValidationFlowActions
import io.novafoundation.nova.common.validation.ValidationStatus
import io.novafoundation.nova.common.validation.progressConsumer
import io.novafoundation.nova.feature_account_api.data.model.Fee
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletModel
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator.Response
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignResponder
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignWallet
import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter
import io.novafoundation.nova.feature_external_sign_impl.R
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationFailure
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ConfirmDAppOperationValidationPayload
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor
import io.novafoundation.nova.feature_wallet_api.domain.validation.handleFeeSpikeDetected
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.model.PaymentCurrencySelectionMode
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.asFeeContextFromSelf
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.awaitOptionalFee
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.createDefaultBy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private typealias SigningError = String
class ExternalSignViewModel(
private val router: ExternalSignRouter,
private val responder: ExternalSignResponder,
private val interactor: ExternalSignInteractor,
private val payload: ExternalSignPayload,
private val validationExecutor: ValidationExecutor,
private val resourceManager: ResourceManager,
walletUiUseCase: WalletUiUseCase,
feeLoaderMixinV2Factory: FeeLoaderMixinV2.Factory,
actionAwaitableMixinFactory: ActionAwaitableMixin.Factory
) : BaseViewModel(),
Validatable by validationExecutor {
val confirmUnrecoverableError = actionAwaitableMixinFactory.confirmingAction<SigningError>()
private val commissionTokenFlow = interactor.utilityAssetFlow()
?.shareInBackground()
val originFeeMixin = commissionTokenFlow?.let {
feeLoaderMixinV2Factory.createDefaultBy(
scope = viewModelScope,
feeContext = it.asFeeContextFromSelf(),
configuration = FeeLoaderMixinV2.Configuration(
showZeroFiat = false,
initialState = FeeLoaderMixinV2.Configuration.InitialState(
paymentCurrencySelectionMode = PaymentCurrencySelectionMode.DETECT_FROM_FEE
)
)
)
}
private val _performingOperationInProgress = MutableStateFlow(false)
val performingOperationInProgress: StateFlow<Boolean> = _performingOperationInProgress
val walletUi = walletUiUseCase.walletUiFor(payload.wallet)
.shareInBackground()
val requestedAccountModel = flowOf {
interactor.createAccountAddressModel()
}
.shareInBackground()
val maybeChainUi = flowOf {
interactor.chainUi()
}
.finishOnFailure()
.shareInBackground()
val dAppInfo = payload.dappMetadata
init {
maybeLoadFee()
}
fun rejectClicked() {
responder.respond(Response.Rejected(payload.signRequest.id))
exit()
}
fun acceptClicked() = launch {
_performingOperationInProgress.value = true
val validationPayload = ConfirmDAppOperationValidationPayload(
fee = originFeeMixin?.awaitOptionalFee()
)
validationExecutor.requireValid(
validationSystem = interactor.validationSystem,
payload = validationPayload,
validationFailureTransformerCustom = ::validationFailureToUi,
autoFixPayload = ::autoFixPayload,
progressConsumer = _performingOperationInProgress.progressConsumer()
) {
performOperation(it.fee)
}
}
private fun performOperation(upToDateFee: Fee?) = launch {
interactor.performOperation(upToDateFee)?.let { response ->
responder.respond(response)
exit()
}
_performingOperationInProgress.value = false
}
private fun maybeLoadFee() {
originFeeMixin?.loadFee { interactor.calculateFee() }
}
private suspend fun respondError(errorMessage: String?) = withContext(Dispatchers.Main) {
val shouldPresent = if (errorMessage != null) {
confirmUnrecoverableError.awaitAction(errorMessage)
false
} else {
true
}
val response = Response.SigningFailed(payload.signRequest.id, shouldPresent)
responder.respond(response)
exit()
}
private suspend fun respondError(error: Throwable) {
val errorMessage = when (error) {
is ExternalSignInteractor.Error.UnsupportedChain -> resourceManager.getString(R.string.dapp_sign_error_unsupported_chain, error.chainId)
else -> null
}
respondError(errorMessage)
}
fun detailsClicked() {
launch {
val extrinsicContent = interactor.readableOperationContent()
router.openExtrinsicDetails(extrinsicContent)
}
}
private fun exit() = launch {
interactor.shutdown()
router.back()
}
private fun validationFailureToUi(
failure: ValidationStatus.NotValid<ConfirmDAppOperationValidationFailure>,
actions: ValidationFlowActions<*>
): TransformedFailure? {
return when (val reason = failure.reason) {
is ConfirmDAppOperationValidationFailure.FeeSpikeDetected -> originFeeMixin?.let {
handleFeeSpikeDetected(
error = reason,
resourceManager = resourceManager,
setFee = originFeeMixin,
actions = actions
)
}
}
}
private fun autoFixPayload(
payload: ConfirmDAppOperationValidationPayload,
failure: ConfirmDAppOperationValidationFailure
): ConfirmDAppOperationValidationPayload {
return when (failure) {
is ConfirmDAppOperationValidationFailure.FeeSpikeDetected -> payload.copy(fee = failure.payload.newFee)
}
}
private fun <T> Flow<Result<T>>.finishOnFailure(): Flow<T?> {
return onEach { result -> result.onFailure { respondError(it) } }
.map { it.getOrNull() }
}
private fun WalletUiUseCase.walletUiFor(externalSignWallet: ExternalSignWallet): Flow<WalletModel> {
return when (externalSignWallet) {
ExternalSignWallet.Current -> selectedWalletUiFlow(showAddressIcon = true)
is ExternalSignWallet.WithId -> walletUiFlow(externalSignWallet.metaId, showAddressIcon = true)
}
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload
import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.ExternalSignFragment
@Subcomponent(
modules = [
ExternalSignModule::class
]
)
@ScreenScope
interface ExternalSignComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: ExternalSignPayload,
): ExternalSignComponent
}
fun inject(fragment: ExternalSignFragment)
}
@@ -0,0 +1,72 @@
package io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.mixin.actionAwaitable.ActionAwaitableMixin
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.validation.ValidationExecutor
import io.novafoundation.nova.feature_account_api.presenatation.account.wallet.WalletUiUseCase
import io.novafoundation.nova.feature_external_sign_api.model.ExternalSignCommunicator
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignPayload
import io.novafoundation.nova.feature_external_sign_api.model.signPayload.ExternalSignRequest
import io.novafoundation.nova.feature_external_sign_impl.ExternalSignRouter
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.ExternalSignInteractor
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm.EvmSignInteractorFactory
import io.novafoundation.nova.feature_external_sign_impl.domain.sign.polkadot.PolkadotSignInteractorFactory
import io.novafoundation.nova.feature_external_sign_impl.presentation.signExtrinsic.ExternalSignViewModel
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.v2.FeeLoaderMixinV2
@Module(includes = [ViewModelModule::class])
class ExternalSignModule {
@Provides
@ScreenScope
fun provideInteractor(
polkadotSignInteractorFactory: PolkadotSignInteractorFactory,
metamaskSignInteractorFactory: EvmSignInteractorFactory,
payload: ExternalSignPayload
): ExternalSignInteractor = when (val request = payload.signRequest) {
is ExternalSignRequest.Polkadot -> polkadotSignInteractorFactory.create(request, payload.wallet)
is ExternalSignRequest.Evm -> metamaskSignInteractorFactory.create(request, payload.wallet)
}
@Provides
@ScreenScope
internal fun provideViewModel(fragment: Fragment, factory: ViewModelProvider.Factory): ExternalSignViewModel {
return ViewModelProvider(fragment, factory).get(ExternalSignViewModel::class.java)
}
@Provides
@IntoMap
@ViewModelKey(ExternalSignViewModel::class)
fun provideViewModel(
router: ExternalSignRouter,
feeLoaderMixinFactory: FeeLoaderMixinV2.Factory,
interactor: ExternalSignInteractor,
payload: ExternalSignPayload,
communicator: ExternalSignCommunicator,
walletUiUseCase: WalletUiUseCase,
validationExecutor: ValidationExecutor,
resourceManager: ResourceManager,
actionAwaitableMixin: ActionAwaitableMixin.Factory
): ViewModel {
return ExternalSignViewModel(
router = router,
interactor = interactor,
feeLoaderMixinV2Factory = feeLoaderMixinFactory,
payload = payload,
responder = communicator,
walletUiUseCase = walletUiUseCase,
validationExecutor = validationExecutor,
resourceManager = resourceManager,
actionAwaitableMixinFactory = actionAwaitableMixin
)
}
}
@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/confirmSignExtinsicToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:dividerVisible="false"
app:homeButtonVisible="false"
app:titleText="@string/common_sign_request" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:gravity="center_horizontal"
android:orientation="vertical">
<ImageView
android:id="@+id/confirmSignExtinsicIcon"
style="@style/Widget.Nova.Icon.Big"
android:layout_marginTop="24dp"
tools:src="@drawable/ic_earth" />
<TextView
android:id="@+id/confirmSignExtinsicTitle"
style="@style/TextAppearance.NovaFoundation.SemiBold.Title3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center_horizontal"
android:text="@string/common_confirm_title"
android:textColor="@color/text_primary" />
<TextView
android:id="@+id/confirmSignExtinsicSubTitle"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center_horizontal"
android:text="@string/dapp_confirm_sign_extrinsic_subtitle"
android:textColor="@color/text_secondary" />
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmSignExtinsicDappUrl"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/dapp_dapp" />
</io.novafoundation.nova.common.view.TableView>
<io.novafoundation.nova.common.view.TableView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp">
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmSignExtinsicWallet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/tabbar_wallet_title" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmSignExtinsicAccount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_account_address" />
<io.novafoundation.nova.common.view.TableCellView
android:id="@+id/confirmSignExtinsicNetwork"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/common_network" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.FeeView
android:id="@+id/confirmSignExtinsicFee"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:title="@string/network_fee" />
</io.novafoundation.nova.common.view.TableView>
<io.novafoundation.nova.common.view.GoNextView
android:id="@+id/confirmSignExtinsicDetails"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/transaction_details_title"
android:textAppearance="@style/GoNextTransactionDetailsTextAppearance"
app:actionIcon="@drawable/ic_chevron_right"
app:dividerVisible="false"
tools:background="@color/block_background" />
</LinearLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:orientation="horizontal">
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/confirmDAppActionReject"
style="@style/Widget.Nova.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:text="@string/common_reject"
android:theme="@style/NegativeAccent"
app:appearance="secondary" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/confirmDAppActionAllow"
style="@style/Widget.Nova.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:text="@string/common_allow"
android:theme="@style/AccentBlue" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/signExtrinsicContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/signExtrinsicToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:contentBackground="@android:color/transparent"
app:dividerVisible="false"
app:homeButtonIcon="@drawable/ic_close"
app:titleText="@string/transaction_details_title" />
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:orientation="vertical">
<TextView
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="@string/dapp_sign_extrinsic_details_subtitle"
android:textColor="@color/text_secondary" />
<TextView
android:id="@+id/extrinsicDetailsContent"
style="@style/TextAppearance.NovaFoundation.Regular.Monospace"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:background="@drawable/extrinsic_details_background"
android:padding="12dp"
android:textColor="@color/text_primary"
tools:text="@tools:sample/lorem[200]" />
<Space
android:layout_width="match_parent"
android:layout_height="24dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</LinearLayout>
@@ -0,0 +1,26 @@
package io.novafoundation.nova.feature_external_sign_impl.domain.sign.evm
import org.junit.Assert.assertArrayEquals
import org.junit.Test
import org.web3j.crypto.Sign
class PersonalSignKtTest {
@Test
fun `should perform personal sign`() {
val message = "Test".encodeToByteArray()
val expected = expectedSign(message)
val actual = message.asEthereumPersonalSignMessage()
assertArrayEquals(expected, actual)
}
// call to reference package-private implementation via reflection.
// It is not good approach to use this as our actual implementation since reflection is unsafe and slow
private fun expectedSign(message: ByteArray): ByteArray {
val method = Sign::class.java.getDeclaredMethod("getEthereumMessageHash", ByteArray::class.java)
method.isAccessible = true
return method.invoke(null, message) as ByteArray
}
}