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
@@ -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
}
}