diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc035c1..160a1cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Support for verifiable presentations (proofs) from identities and accounts + ## [1.16.1] - 2025-12-05 ### Fixed diff --git a/app/build.gradle b/app/build.gradle index 9de7fcb2..1f554342 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,10 +22,10 @@ android { // This project uses semantic versioning // https://semver.org/ def versionMajor = 1 - def versionMinor = 16 - def versionPatch = 1 - def versionMeta = "" - def versionCodeIncremental = 1487 + def versionMinor = 17 + def versionPatch = 0 + def versionMeta = "-qa.1" + def versionCodeIncremental = 1490 defaultConfig { applicationId "com.pioneeringtechventures.wallet" @@ -244,7 +244,7 @@ allprojects { dependencies { - implementation("com.concordium.sdk:concordium-android-sdk:11.1.0") { + implementation("com.concordium.sdk:concordium-android-sdk:11.2.1") { exclude group: "org.bouncycastle" exclude group: "net.jcip" } diff --git a/app/src/main/java/com/concordium/wallet/data/cryptolib/PrivateIdObjectData.kt b/app/src/main/java/com/concordium/wallet/data/cryptolib/PrivateIdObjectData.kt new file mode 100644 index 00000000..8714414b --- /dev/null +++ b/app/src/main/java/com/concordium/wallet/data/cryptolib/PrivateIdObjectData.kt @@ -0,0 +1,21 @@ +package com.concordium.wallet.data.cryptolib + +import com.concordium.wallet.data.room.Identity + +/** + * Decrypted contents of [Identity.privateIdObjectDataEncrypted], + * for file-based identities. + */ +class PrivateIdObjectData( + val randomness: String, + val aci: Aci, +) { + class Aci( + val prfKey: String, + val credentialHolderInformation: CredentialHolderInformation, + ) { + class CredentialHolderInformation( + val idCredSecret: String, + ) + } +} diff --git a/app/src/main/java/com/concordium/wallet/data/model/ArsInfo.kt b/app/src/main/java/com/concordium/wallet/data/model/ArsInfo.kt index 108ce9a0..fe907750 100644 --- a/app/src/main/java/com/concordium/wallet/data/model/ArsInfo.kt +++ b/app/src/main/java/com/concordium/wallet/data/model/ArsInfo.kt @@ -1,9 +1,19 @@ package com.concordium.wallet.data.model +import com.concordium.sdk.responses.blocksummary.updates.queues.AnonymityRevokerInfo +import com.concordium.sdk.serializing.JsonMapper +import com.concordium.wallet.App import java.io.Serializable data class ArsInfo( val arIdentity: Int, val arPublicKey: String, - val arDescription: ArDescription -) : Serializable \ No newline at end of file + val arDescription: ArDescription, +) : Serializable { + + fun toSdkAnonymityRevokerInfo(): AnonymityRevokerInfo = + JsonMapper.INSTANCE.readValue( + App.appCore.gson.toJson(this), + AnonymityRevokerInfo::class.java + ) +} diff --git a/app/src/main/java/com/concordium/wallet/data/model/GlobalParams.kt b/app/src/main/java/com/concordium/wallet/data/model/GlobalParams.kt index f1567276..8d39738c 100644 --- a/app/src/main/java/com/concordium/wallet/data/model/GlobalParams.kt +++ b/app/src/main/java/com/concordium/wallet/data/model/GlobalParams.kt @@ -1,9 +1,20 @@ package com.concordium.wallet.data.model +import com.concordium.sdk.crypto.bulletproof.BulletproofGenerators +import com.concordium.sdk.crypto.pedersencommitment.PedersenCommitmentKey +import com.concordium.sdk.responses.cryptographicparameters.CryptographicParameters import java.io.Serializable data class GlobalParams( val onChainCommitmentKey: String, val bulletproofGenerators: String, - val genesisString: String -) : Serializable + val genesisString: String, +) : Serializable { + + fun toSdkCryptographicParameters(): CryptographicParameters = + CryptographicParameters.builder() + .genesisString(genesisString) + .bulletproofGenerators(BulletproofGenerators.from(bulletproofGenerators)) + .onChainCommitmentKey(PedersenCommitmentKey.from(onChainCommitmentKey)) + .build() +} diff --git a/app/src/main/java/com/concordium/wallet/data/model/IdentityObject.kt b/app/src/main/java/com/concordium/wallet/data/model/IdentityObject.kt index 3b28f8e6..38c20ef0 100644 --- a/app/src/main/java/com/concordium/wallet/data/model/IdentityObject.kt +++ b/app/src/main/java/com/concordium/wallet/data/model/IdentityObject.kt @@ -1,5 +1,8 @@ package com.concordium.wallet.data.model +import com.concordium.sdk.crypto.wallet.identityobject.IdentityObject +import com.concordium.sdk.serializing.JsonMapper +import com.concordium.wallet.App import com.concordium.wallet.core.gson.RawJsonTypeAdapter import com.google.gson.annotations.JsonAdapter import java.io.Serializable @@ -8,5 +11,18 @@ data class IdentityObject( val attributeList: AttributeList, val preIdentityObject: PreIdentityObject, @JsonAdapter(RawJsonTypeAdapter::class) - val signature: RawJson -) : Serializable + val signature: RawJson, +) : Serializable { + + @Transient + private var memoizedSdkIdentityObject: IdentityObject? = null + fun toSdkIdentityObject(): IdentityObject = + memoizedSdkIdentityObject + ?: JsonMapper + .INSTANCE + .readValue( + App.appCore.gson.toJson(this), + IdentityObject::class.java + ) + .also { memoizedSdkIdentityObject = it } +} diff --git a/app/src/main/java/com/concordium/wallet/data/model/IdentityProviderInfo.kt b/app/src/main/java/com/concordium/wallet/data/model/IdentityProviderInfo.kt index c3f023d0..ae40e515 100644 --- a/app/src/main/java/com/concordium/wallet/data/model/IdentityProviderInfo.kt +++ b/app/src/main/java/com/concordium/wallet/data/model/IdentityProviderInfo.kt @@ -1,10 +1,20 @@ package com.concordium.wallet.data.model +import com.concordium.sdk.responses.blocksummary.updates.queues.IdentityProviderInfo +import com.concordium.sdk.serializing.JsonMapper +import com.concordium.wallet.App import java.io.Serializable data class IdentityProviderInfo( val ipIdentity: Int, val ipDescription: IdentityProviderDescription, val ipVerifyKey: String, - val ipCdiVerifyKey: String -) : Serializable \ No newline at end of file + val ipCdiVerifyKey: String, +) : Serializable { + + fun toSdkIdentityProviderInfo(): IdentityProviderInfo = + JsonMapper.INSTANCE.readValue( + App.appCore.gson.toJson(this), + IdentityProviderInfo::class.java + ) +} diff --git a/app/src/main/java/com/concordium/wallet/data/model/SubmissionStatusResponse.kt b/app/src/main/java/com/concordium/wallet/data/model/SubmissionStatusResponse.kt index e2d12e7b..46012f4f 100644 --- a/app/src/main/java/com/concordium/wallet/data/model/SubmissionStatusResponse.kt +++ b/app/src/main/java/com/concordium/wallet/data/model/SubmissionStatusResponse.kt @@ -13,5 +13,6 @@ data class SubmissionStatusResponse( val blockHashes: List?, val rejectReason: String?, val encryptedAmount: String?, - val aggregatedIndex: Int? + val aggregatedIndex: Int?, + val registeredData: String?, ) diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/ChooseIdentityListAdapter.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/ChooseIdentityListAdapter.kt new file mode 100644 index 00000000..1dafcb6f --- /dev/null +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/ChooseIdentityListAdapter.kt @@ -0,0 +1,56 @@ +package com.concordium.wallet.ui.walletconnect + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.BaseAdapter +import androidx.core.view.isVisible +import com.concordium.wallet.data.room.Identity +import com.concordium.wallet.databinding.AccountInfoRowBinding + +class ChooseIdentityListAdapter( + private val context: Context, + private var arrayList: List, +) : BaseAdapter() { + private var clickListener: ((Identity) -> Unit)? = null + + fun setOnClickListener(listener: (Identity) -> Unit) { + this.clickListener = listener + } + + override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View { + val binding: AccountInfoRowBinding + if (convertView == null) { + binding = AccountInfoRowBinding.inflate(LayoutInflater.from(context), parent, false) + binding.root.tag = binding + } else { + binding = convertView.tag as AccountInfoRowBinding + } + + val identity = arrayList[position] + + with(binding) { + accAddress.text = identity.name + accBalance.isVisible = false + accIdentity.isVisible = false + accBalanceAtDisposal.isVisible = false + + root.setOnClickListener { + clickListener?.invoke(identity) + } + } + + return binding.root + } + + override fun getCount(): Int = arrayList.size + + override fun getItem(position: Int): Any? { + return null + } + + override fun getItemId(position: Int): Long { + return 0 + } +} diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/CredentialStatementAdapter.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/CredentialStatementAdapter.kt index 65dca13e..b19120e2 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/CredentialStatementAdapter.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/CredentialStatementAdapter.kt @@ -4,17 +4,12 @@ import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView -import com.concordium.sdk.crypto.wallet.web3Id.Statement.RequestStatement -import com.concordium.wallet.data.room.Account -import com.concordium.wallet.data.room.Identity import com.concordium.wallet.databinding.IdentityProofContainerBinding -import com.concordium.wallet.util.Log class CredentialStatementAdapter( - private val statements: List, - private val accounts: List, - private val getIdentity: (account: Account) -> Identity?, - private val onChangeAccountClicked: (index: Int) -> Unit + private val claims: List, + private val onChangeAccountClicked: (index: Int) -> Unit, + private val onIdentityChangeClicked: (index: Int) -> Unit, ) : RecyclerView.Adapter() { class ViewHolder(val containerBinding: IdentityProofContainerBinding) : RecyclerView.ViewHolder(containerBinding.root) @@ -29,27 +24,38 @@ class CredentialStatementAdapter( } override fun getItemCount(): Int { - return statements.size + return claims.size } override fun onBindViewHolder(holder: ViewHolder, position: Int) { - val account = accounts[position] - val identity = getIdentity(account) + val selectedCredential = claims[position].selectedCredential + val identity = selectedCredential.identity - if (identity == null) { - Log.e("Identity is not available for account ${account.address}") - return - } + holder.containerBinding.statements.setStatement(claims[position], identity) - holder.containerBinding.statements.setStatement(statements[position], identity) - with(holder.containerBinding.selectedAccountInclude) { - accAddress.text = account.getAccountName() - accBalance.isVisible = false - accIdentity.isVisible = true - accIdentity.text = identity.name - } - holder.containerBinding.selectedAccountIncludeContainer.setOnClickListener { - onChangeAccountClicked(position) + when (selectedCredential) { + is IdentityProofRequestSelectedCredential.Account -> { + with(holder.containerBinding.selectedCredentialInclude) { + accAddress.text = selectedCredential.account.getAccountName() + accBalance.isVisible = false + accIdentity.isVisible = true + accIdentity.text = identity.name + } + holder.containerBinding.selectedCredentialInclude.root.setOnClickListener { + onChangeAccountClicked(position) + } + } + + is IdentityProofRequestSelectedCredential.Identity -> { + with(holder.containerBinding.selectedCredentialInclude) { + accAddress.text = identity.name + accBalance.isVisible = false + accIdentity.isVisible = false + } + holder.containerBinding.selectedCredentialInclude.root.setOnClickListener{ + onIdentityChangeClicked(position) + } + } } } } diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/DisplayStatements.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/DisplayStatements.kt index bc3333da..f269622f 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/DisplayStatements.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/DisplayStatements.kt @@ -5,14 +5,11 @@ import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout -import com.concordium.sdk.crypto.wallet.identityobject.AttributeList -import com.concordium.sdk.crypto.wallet.identityobject.IdentityObject import com.concordium.sdk.crypto.wallet.web3Id.CredentialAttribute import com.concordium.sdk.crypto.wallet.web3Id.Statement.AtomicStatement import com.concordium.sdk.crypto.wallet.web3Id.Statement.MembershipStatement import com.concordium.sdk.crypto.wallet.web3Id.Statement.NonMembershipStatement import com.concordium.sdk.crypto.wallet.web3Id.Statement.RangeStatement -import com.concordium.sdk.crypto.wallet.web3Id.Statement.RequestStatement import com.concordium.sdk.crypto.wallet.web3Id.Statement.RevealStatement import com.concordium.sdk.crypto.wallet.web3Id.Statement.SetStatement import com.concordium.sdk.responses.accountinfo.credential.AttributeType @@ -39,12 +36,12 @@ class DisplayStatements(context: Context, attrs: AttributeSet): LinearLayout(con addView(binding.root) } - fun setStatement(request: RequestStatement, identity: Identity) { + fun setStatement(request: IdentityProofRequestClaims, identity: Identity) { binding.revealStatements.revealLines.removeAllViews() binding.secretStatements.secretLines.removeAllViews() - val secretStatements = request.statement.filterNot { it is RevealStatement } - val revealStatements = request.statement.filterIsInstance() + val secretStatements = request.statements.filterNot { it is RevealStatement } + val revealStatements = request.statements.filterIsInstance() if (secretStatements.isEmpty()) { // If there are no reveal statements, then don't show the reveal box @@ -52,7 +49,10 @@ class DisplayStatements(context: Context, attrs: AttributeSet): LinearLayout(con } else { binding.secretStatements.root.visibility = VISIBLE secretStatements.forEach { - binding.secretStatements.secretLines.addView(getSecretStatement(it, it.canBeProvedBy((getIdentityObject(identity))))) + binding.secretStatements.secretLines.addView(getSecretStatement( + it, + it.canBeProvedBy(identity.identityObject!!.toSdkIdentityObject()) + )) } } @@ -325,14 +325,3 @@ class DisplayStatements(context: Context, attrs: AttributeSet): LinearLayout(con val EU_MEMBERS = listOf("AT", "BE", "BG", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", "HR") } } - - -/** - * Get an IdentityObject compatible with the Concordium SDK methods. - * N.B. Only the attributeList is populated, the remaining fields are null - */ -fun getIdentityObject(identity: Identity): IdentityObject { - val identityObject = identity.identityObject!! - val attributes = AttributeList.builder().chosenAttributes(identityObject.attributeList.chosenAttributes.mapKeys { AttributeType.fromJSON(it.key) }).build() - return IdentityObject.builder().attributeList(attributes).build() -} diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/IdentityProofRequestClaims.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/IdentityProofRequestClaims.kt new file mode 100644 index 00000000..aa4c974d --- /dev/null +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/IdentityProofRequestClaims.kt @@ -0,0 +1,12 @@ +package com.concordium.wallet.ui.walletconnect + +import com.concordium.sdk.crypto.wallet.web3Id.Statement.AtomicStatement + +/** + * Some statements about identity which can be proven + * by either account, identity, or both. + */ +data class IdentityProofRequestClaims( + val statements: List, + val selectedCredential: IdentityProofRequestSelectedCredential, +) diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/IdentityProofRequestSelectedCredential.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/IdentityProofRequestSelectedCredential.kt new file mode 100644 index 00000000..fc7ff14d --- /dev/null +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/IdentityProofRequestSelectedCredential.kt @@ -0,0 +1,15 @@ +package com.concordium.wallet.ui.walletconnect + +sealed interface IdentityProofRequestSelectedCredential { + + val identity: com.concordium.wallet.data.room.Identity + + class Account( + val account: com.concordium.wallet.data.room.Account, + override val identity: com.concordium.wallet.data.room.Identity, + ) : + IdentityProofRequestSelectedCredential + + class Identity(override val identity: com.concordium.wallet.data.room.Identity) : + IdentityProofRequestSelectedCredential +} diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectBottomSheet.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectBottomSheet.kt index 20559750..02bf2585 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectBottomSheet.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectBottomSheet.kt @@ -57,6 +57,19 @@ class WalletConnectBottomSheet : BottomSheetDialogFragment( } } + fun showIdentitySelection( + onShown: (createdView: WalletConnectAccountSelectionFragment.CreatedView) -> Unit + ) { + // For now, without design, the account selection fragment is re-used. + val fragment: WalletConnectAccountSelectionFragment = + getShownFragment(IDENTITY_SELECTION_TAG) + + fragment.createdView.observe(this) { createdView -> + onShown(createdView) + fragment.createdView.removeObservers(this) + } + } + fun showTransactionRequestReview( onShown: (createdView: WalletConnectTransactionRequestReviewFragment.CreatedView) -> Unit ) { @@ -129,6 +142,7 @@ class WalletConnectBottomSheet : BottomSheetDialogFragment( private companion object { private const val SESSION_PROPOSAL_REVIEW_TAG = "wc_spr" private const val ACCOUNT_SELECTION_TAG = "wc_acc" + private const val IDENTITY_SELECTION_TAG = "wc_identity" private const val TRANSACTION_REQUEST_REVIEW_TAG = "wc_trr" private const val SIGN_REQUEST_REVIEW_TAG = "wc_srr" private const val IDENTITY_PROOF_REQUEST_REVIEW_TAG = "wc_id" diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignMessageRequestHandler.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignMessageRequestHandler.kt index 3d572123..dcc99e66 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignMessageRequestHandler.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignMessageRequestHandler.kt @@ -147,4 +147,8 @@ class WalletConnectSignMessageRequestHandler( Log.w("Nothing to show as details for ${signMessageParams::class.simpleName}") } } + + companion object { + const val METHOD = "sign_message" + } } diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignTransactionRequestHandler.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignTransactionRequestHandler.kt index 2550d8e6..5165f435 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignTransactionRequestHandler.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectSignTransactionRequestHandler.kt @@ -545,4 +545,8 @@ class WalletConnectSignTransactionRequestHandler( is AccountTransactionPayload.Update -> transactionPayload.receiveName } + + companion object{ + const val METHOD = "sign_and_send_transaction" + } } diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationRequestHandler.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationRequestHandler.kt index 09a0b2e4..904b51d2 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationRequestHandler.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationRequestHandler.kt @@ -1,7 +1,5 @@ package com.concordium.wallet.ui.walletconnect -import com.concordium.sdk.crypto.bulletproof.BulletproofGenerators -import com.concordium.sdk.crypto.pedersencommitment.PedersenCommitmentKey import com.concordium.sdk.crypto.wallet.Network import com.concordium.sdk.crypto.wallet.web3Id.AcceptableRequest import com.concordium.sdk.crypto.wallet.web3Id.AccountCommitmentInput @@ -22,7 +20,6 @@ import com.concordium.wallet.data.IdentityRepository import com.concordium.wallet.data.backend.repository.ProxyRepository import com.concordium.wallet.data.cryptolib.AttributeRandomness import com.concordium.wallet.data.cryptolib.StorageAccountData -import com.concordium.wallet.data.model.GlobalParams import com.concordium.wallet.data.room.Account import com.concordium.wallet.data.room.Identity import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.Error @@ -54,7 +51,7 @@ class WalletConnectVerifiablePresentationRequestHandler( private lateinit var identityProofProvableState: WalletConnectViewModel.ProofProvableState private lateinit var identitiesById: Map private lateinit var accountsPerStatement: MutableList - private lateinit var globalParams: GlobalParams + private lateinit var globalContext: CryptographicParameters suspend fun start( params: String, @@ -124,7 +121,7 @@ class WalletConnectVerifiablePresentationRequestHandler( // otherwise find any account that can prove the statement. orderedAccounts.find { account -> isValidIdentityForStatement( - identity = getIdentity(account)!!, + identity = getIdentity(account), statement = statement, ) } @@ -136,7 +133,7 @@ class WalletConnectVerifiablePresentationRequestHandler( identityProofRequest.credentialStatements.any { statement -> availableAccounts.none { account -> (statement.idQualifier as IdentityQualifier).issuers.contains( - getIdentity(account)!!.identityProviderId.toLong() + getIdentity(account).identityProviderId.toLong() ) } } @@ -160,7 +157,8 @@ class WalletConnectVerifiablePresentationRequestHandler( private suspend fun loadDataAndPresentReview() { try { - this.globalParams = getGlobalParams() + this.globalContext = getGlobalParams() + .toSdkCryptographicParameters() } catch (e: Exception) { Log.e("Failed loading global params", e) @@ -235,7 +233,7 @@ class WalletConnectVerifiablePresentationRequestHandler( val statementAccount = accountIterator.next() val attributeRandomness = attributeRandomnessByAccount.getValue(statementAccount.address) - val statementIdentity = getIdentity(statementAccount)!! + val statementIdentity = getIdentity(statementAccount) val identityProviderIndex = statementIdentity.identityProviderId val randomness: MutableMap = mutableMapOf() val attributeValues: MutableMap = mutableMapOf() @@ -288,26 +286,25 @@ class WalletConnectVerifiablePresentationRequestHandler( val proofInput = Web3IdProofInput.builder() .request(qualifiedRequest) .commitmentInputs(commitmentInputs) - .globalContext( - CryptographicParameters.builder() - .genesisString(globalParams.genesisString) - .bulletproofGenerators(BulletproofGenerators.from(globalParams.bulletproofGenerators)) - .onChainCommitmentKey(PedersenCommitmentKey.from(globalParams.onChainCommitmentKey)) - .build() - ) + .globalContext(globalContext) .build() try { val proof = Web3IdProof.getWeb3IdProof(proofInput) - val wrappedProof = VerifiablePresentationWrapper(proof) - respondSuccess(App.appCore.gson.toJson(wrappedProof)) + respondSuccess( + App.appCore.gson.toJson( + mapOf( + "verifiablePresentationJson" to proof + ) + ) + ) onFinish() } catch (e: Exception) { Log.e("Failed creating verifiable presentation", e) - respondError("Unable to create verifiable presentation: internal error") + respondError("Unable to create verifiable presentation: $e") emitEvent( Event.ShowFloatingError( @@ -323,11 +320,8 @@ class WalletConnectVerifiablePresentationRequestHandler( ) { val statement = identityProofRequest.credentialStatements[statementIndex] - val validAccounts = availableAccounts.filter { account -> - getIdentity(account) - ?.let { isValidIdentityForStatement(it, statement) } - ?: false + isValidIdentityForStatement(getIdentity(account), statement) } if (validAccounts.size > 1) { @@ -338,10 +332,9 @@ class WalletConnectVerifiablePresentationRequestHandler( emitState( State.AccountSelection( - selectedAccount = accountsPerStatement[statementIndex], accounts = validAccounts, appMetadata = appMetadata, - identityProofPosition = statementIndex, + previousState = createIdentityProofRequestState(statementIndex), ) ) } else { @@ -360,24 +353,16 @@ class WalletConnectVerifiablePresentationRequestHandler( ) } - fun onAccountSelectionBackPressed( - statementIndex: Int, - ) { - emitState( - createIdentityProofRequestState(statementIndex) - ) - } - - fun getIdentity(account: Account) = - identitiesById[account.identityId] + private fun getIdentity(account: Account) = + identitiesById[account.identityId]!! private fun isValidIdentityForStatement( identity: Identity, - statement: UnqualifiedRequestStatement + statement: UnqualifiedRequestStatement, ): Boolean = statement.idQualifier is IdentityQualifier && (statement.idQualifier as IdentityQualifier).issuers.contains(identity.identityProviderId.toLong()) - && statement.canBeProvedBy(getIdentityObject(identity)) + && statement.canBeProvedBy(identity.identityObject!!.toSdkIdentityObject()) private fun onInvalidRequest(responseMessage: String, e: Exception? = null) { if (e == null) Log.e(responseMessage) else Log.e(responseMessage, e) @@ -399,26 +384,33 @@ class WalletConnectVerifiablePresentationRequestHandler( State.SessionRequestReview.IdentityProofRequestReview( connectedAccount = accountsPerStatement.first(), appMetadata = appMetadata, - request = identityProofRequest, - chosenAccounts = accountsPerStatement, - currentStatement = currentStatementIndex, - provable = identityProofProvableState + claims = + identityProofRequest + .credentialStatements + .zip(accountsPerStatement) + .map { (credentialStatement, account) -> + IdentityProofRequestClaims( + statements = credentialStatement.statement, + selectedCredential = IdentityProofRequestSelectedCredential.Account( + account = account, + identity = getIdentity(account), + ), + ) + }, + currentClaim = currentStatementIndex, + provable = identityProofProvableState, + isV1 = false, ) - /** - * Wrapper for sending the verifiable presentation as JSON over WalletConnect. This is - * required to prevent WalletConnect from automatically parsing the JSON as an object - * on the dApp side. - */ - private class VerifiablePresentationWrapper( - val verifiablePresentationJson: String - ) - /** * Wrapper for receiving parameters as JSON over WalletConnect. This is required to allow a * dApp to send bigint values from the Javascript side. */ private class WalletConnectParamsWrapper( - val paramsJson: String + val paramsJson: String, ) + + companion object { + const val METHOD = "request_verifiable_presentation" + } } diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationV1RequestHandler.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationV1RequestHandler.kt new file mode 100644 index 00000000..6e39467d --- /dev/null +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectVerifiablePresentationV1RequestHandler.kt @@ -0,0 +1,690 @@ +package com.concordium.wallet.ui.walletconnect + +import com.concordium.sdk.crypto.bls.BLSSecretKey +import com.concordium.sdk.crypto.wallet.ConcordiumHdWallet +import com.concordium.sdk.crypto.wallet.Network +import com.concordium.sdk.crypto.wallet.web3Id.GivenContext +import com.concordium.sdk.crypto.wallet.web3Id.IdentityClaims +import com.concordium.sdk.crypto.wallet.web3Id.IdentityClaimsAccountProofInput +import com.concordium.sdk.crypto.wallet.web3Id.IdentityClaimsIdentityProofInput +import com.concordium.sdk.crypto.wallet.web3Id.VerifiablePresentationV1 +import com.concordium.sdk.crypto.wallet.web3Id.VerificationRequestAnchor +import com.concordium.sdk.crypto.wallet.web3Id.VerificationRequestV1 +import com.concordium.sdk.responses.accountinfo.credential.AttributeType +import com.concordium.sdk.responses.cryptographicparameters.CryptographicParameters +import com.concordium.sdk.serializing.CborMapper +import com.concordium.sdk.serializing.JsonMapper +import com.concordium.sdk.transactions.CredentialRegistrationId +import com.concordium.sdk.types.UInt32 +import com.concordium.wallet.App +import com.concordium.wallet.BuildConfig +import com.concordium.wallet.core.multiwallet.AppWallet +import com.concordium.wallet.data.IdentityRepository +import com.concordium.wallet.data.backend.repository.ProxyRepository +import com.concordium.wallet.data.cryptolib.PrivateIdObjectData +import com.concordium.wallet.data.cryptolib.StorageAccountData +import com.concordium.wallet.data.model.SubmissionStatusResponse +import com.concordium.wallet.data.preferences.WalletSetupPreferences +import com.concordium.wallet.data.room.Account +import com.concordium.wallet.data.room.Identity +import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.Error +import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.Event +import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.State +import com.concordium.wallet.util.Log +import com.reown.util.hexToBytes +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class WalletConnectVerifiablePresentationV1RequestHandler( + private val respondSuccess: (result: String) -> Unit, + private val respondError: (message: String) -> Unit, + private val emitEvent: (event: Event) -> Unit, + private val emitState: (state: State) -> Unit, + onFinish: () -> Unit, + private val setIsLoading: (isLoading: Boolean) -> Unit, + private val proxyRepository: ProxyRepository, + private val identityRepository: IdentityRepository, + private val walletSetupPreferences: WalletSetupPreferences, + private val activeWalletType: AppWallet.Type, +) { + private val network = + if (BuildConfig.ENV_NAME.equals("production", ignoreCase = true)) + Network.MAINNET + else + Network.TESTNET + private val isLoadingData: MutableStateFlow = MutableStateFlow(false) + private val isCreatingProof: MutableStateFlow = MutableStateFlow(false) + private var requestCoroutineScope: CoroutineScope? = null + private lateinit var deferredDataLoading: Deferred + private val onFinish: () -> Unit = { + requestCoroutineScope?.cancel() + onFinish() + } + + // Every WC request is associated with an account, + // but this doesn't really matter for proofs. + // What matters is credentialsByClaims. + private lateinit var connectedAccount: Account + private lateinit var appMetadata: WalletConnectViewModel.AppMetadata + private lateinit var verificationRequest: VerificationRequestV1 + private lateinit var identityClaims: List + private lateinit var identityProofProvableState: WalletConnectViewModel.ProofProvableState + private lateinit var identitiesById: Map + private lateinit var credentialsByClaims: MutableMap + + suspend fun start( + params: String, + connectedAccount: Account, + availableAccounts: Collection, + appMetadata: WalletConnectViewModel.AppMetadata, + ) { + requestCoroutineScope?.cancel() + requestCoroutineScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + + isLoadingData.value = false + isCreatingProof.value = false + + combine( + isLoadingData, + isCreatingProof, + transform = { one, another -> + one || another + } + ) + .onEach(setIsLoading) + .launchIn(requestCoroutineScope!!) + + var anyUnprovableClaims = false + + try { + this.verificationRequest = JsonMapper.INSTANCE.readValue( + params, + VerificationRequestV1::class.java + ) + + this.identityClaims = + verificationRequest + .subjectClaims + .map { claims -> + claims as? IdentityClaims + ?: error("Only identity claims are supported") + } + .takeIf(Collection<*>::isNotEmpty) + ?: error("There are no claims") + + this.identitiesById = + identityRepository + .getAllDone() + .associateBy(Identity::id) + + // To show the request, all claims must have an associated identity or account. + // If claims are not provable, use the connected account as a fallback. + this.credentialsByClaims = identityClaims.associateWithTo(mutableMapOf()) { claims -> + when { + claims.areIdentitiesAccepted() -> { + val suitableIdentity: Identity? = + identitiesById + .values + .find { claims.canBeProvedBy(it) } + + if (suitableIdentity == null) { + anyUnprovableClaims = true + } + + IdentityProofRequestSelectedCredential.Identity( + identity = suitableIdentity ?: getIdentity(connectedAccount), + ) + } + + claims.areAccountsAccepted() -> { + val suitableAccount: Account? = + availableAccounts + .find { account -> + claims.canBeProvedBy(getIdentity(account)) + } + + if (suitableAccount == null) { + anyUnprovableClaims = true + } + + IdentityProofRequestSelectedCredential.Account( + account = suitableAccount ?: connectedAccount, + identity = getIdentity(suitableAccount ?: connectedAccount), + ) + } + + else -> + error("Only claims for account or identity credentials are supported") + } + } + } catch (e: Exception) { + Log.e("Failed parsing the request", e) + + respondError("Failed parsing the request: $e") + + emitEvent( + Event.ShowFloatingError( + Error.InvalidRequest + ) + ) + + onFinish() + + return + } + + val anyAccountOnlyClaims = + identityClaims + .any { claims -> + claims.areAccountsAccepted() && claims.source.size == 1 + } + + if (anyAccountOnlyClaims && activeWalletType != AppWallet.Type.SEED) { + Log.d("A non-seed phrase wallet only support claims for identity credentials") + + respondError("A non-seed phrase wallet only support claims for identity credentials") + + emitEvent( + Event.ShowFloatingError( + Error.NotSeedPhraseWalletError + ) + ) + + onFinish() + + return + } + + this.connectedAccount = connectedAccount + this.appMetadata = appMetadata + + this.identityProofProvableState = when { + anyUnprovableClaims -> { + val identityProviderIds = + identitiesById + .values + .mapTo(mutableSetOf(), Identity::identityProviderId) + val anyClaimsForMissingIssuers = + identityClaims + .any { claims -> + claims.issuers.none { it.ipIdentity.value in identityProviderIds } + } + + if (anyClaimsForMissingIssuers) + WalletConnectViewModel.ProofProvableState.NoCompatibleIssuer + else + WalletConnectViewModel.ProofProvableState.UnProvable + } + + else -> + WalletConnectViewModel.ProofProvableState.Provable + } + + loadDataAndPresentReview() + } + + private suspend fun loadDataAndPresentReview() { + deferredDataLoading = requestCoroutineScope!!.async { + check(identityProofProvableState == WalletConnectViewModel.ProofProvableState.Provable) { + "No point in loading the data for unprovable proof" + } + + isLoadingData.value = true + + Log.d("Loading necessary data in background") + + val globalParams = async { + getGlobalParams().toSdkCryptographicParameters() + } + val verifiedAnchorBlockHash = async { + getVerifiedAnchorBlockHash() + } + + LoadedData( + globalParams.await(), + verifiedAnchorBlockHash.await(), + ).also { + isLoadingData.value = false + Log.d("Necessary data loaded") + } + } + + emitState( + createIdentityProofRequestState( + currentClaimIndex = 0, + ) + ) + } + + private suspend fun getGlobalParams( + ) = suspendCancellableCoroutine { continuation -> + val backendRequest = proxyRepository.getIGlobalInfo( + success = { continuation.resume(it.value) }, + failure = continuation::resumeWithException, + ) + continuation.invokeOnCancellation { backendRequest.dispose() } + } + + private suspend fun getVerifiedAnchorBlockHash( + ): String = withContext(Dispatchers.IO) { + var requestAnchorTransaction: SubmissionStatusResponse? = null + + // For now, the wallet needs to wait for a transaction to be included into a block. + // Not necessarily finalized. + for (attempt in (1..5)) { + Log.d("Attempt #$attempt to get the anchor transaction") + + delay(1000) + + requestAnchorTransaction = runCatching { + proxyRepository + .getSubmissionStatus(verificationRequest.transactionRef.asHex()) + .takeUnless { it.blockHashes.isNullOrEmpty() } + }.getOrNull() + + if (requestAnchorTransaction != null) { + break + } + } + + val blockHash = requestAnchorTransaction + ?.blockHashes + ?.first() + ?: error("Failed getting anchor transaction block hash in time") + + val anchorCborHex = requestAnchorTransaction.registeredData + ?: error("Anchor transaction data is missing") + + val anchor = CborMapper.INSTANCE.readValue( + anchorCborHex.hexToBytes(), + VerificationRequestAnchor::class.java + ) + + check(verificationRequest.verifyAnchor(anchor)) { + "Anchor doesn't match the request" + } + + return@withContext blockHash + } + + suspend fun onAuthorizedForApproval( + password: String, + ) = withContext(Dispatchers.Default) { + isCreatingProof.value = true + + val (globalParams, anchorBlockHash) = try { + deferredDataLoading.await() + } catch (e: Exception) { + ensureActive() + + Log.e("Failed loading necessary data", e) + + respondError("Failed loading necessary data: $e") + + emitEvent( + Event.ShowFloatingError( + Error.LoadingFailed + ) + ) + + onFinish() + + return@withContext + } + + try { + // Both file- and seed-based IDs can do V1 proofs, + // the keys are either derived or decrypted. + + val hdWallet: ConcordiumHdWallet? = + walletSetupPreferences + .takeIf(WalletSetupPreferences::hasEncryptedSeed) + ?.let { walletSetupPreferences -> + ConcordiumHdWallet.fromHex( + walletSetupPreferences.getSeedHex(password), + network + ) + } + + val proofInputs = credentialsByClaims + .map { (identityClaims, credential) -> + when (credential) { + is IdentityProofRequestSelectedCredential.Account -> + getAccountProofInput( + identityClaims = identityClaims, + account = credential.account, + identity = credential.identity, + password = password, + ) + + is IdentityProofRequestSelectedCredential.Identity -> + getIdentityProofInput( + identityClaims = identityClaims, + identity = credential.identity, + password = password, + hdWallet = hdWallet, + ) + } + } + + val providedContext = + verificationRequest + .context + .requested + .map { requestedInfoLabel -> + val info = when (requestedInfoLabel) { + GivenContext.BLOCK_HASH_LABEL -> + anchorBlockHash + + GivenContext.RESOURCE_ID_LABEL -> + "(╬▔皿▔)╯📦 you're welcome" + + else -> + error("Can't provide $requestedInfoLabel") + } + GivenContext(requestedInfoLabel, info) + } + + val proof = VerifiablePresentationV1.getVerifiablePresentation( + verificationRequest, + proofInputs, + providedContext, + globalParams + ) + + respondSuccess("""{ "verifiablePresentationJson": $proof }""") + + onFinish() + } catch (e: Exception) { + Log.e("Failed creating verifiable presentation", e) + + respondError("Failed creating verifiable presentation: $e") + + emitEvent( + Event.ShowFloatingError( + Error.InternalError + ) + ) + + onFinish() + } + } + + private suspend fun getIdentityProofInput( + identityClaims: IdentityClaims, + identity: Identity, + password: String, + hdWallet: ConcordiumHdWallet?, + ): IdentityClaimsIdentityProofInput = withContext(Dispatchers.Default) { + val identityProvider = identity.identityProvider + + val idCredSec: BLSSecretKey + val prfKey: BLSSecretKey + val signatureBlindingRandomness: String + + when { + hdWallet != null -> { + idCredSec = hdWallet.getIdCredSec( + identityProvider.ipInfo.ipIdentity, + identity.identityIndex + ) + prfKey = hdWallet.getPrfKey( + identityProvider.ipInfo.ipIdentity, + identity.identityIndex + ) + signatureBlindingRandomness = hdWallet.getSignatureBlindingRandomness( + identityProvider.ipInfo.ipIdentity, + identity.identityIndex + ) + } + + identity.privateIdObjectDataEncrypted != null -> { + val privateData: PrivateIdObjectData = + App + .appCore + .auth + .decrypt( + password = password, + encryptedData = identity.privateIdObjectDataEncrypted!!, + ) + ?.let { decryptedBytes -> + App.appCore.gson.fromJson( + String(decryptedBytes), + PrivateIdObjectData::class.java + ) + } + ?: error("Can't decrypt identity private data") + + signatureBlindingRandomness = privateData.randomness + prfKey = + privateData + .aci + .prfKey + .let(BLSSecretKey::from) + idCredSec = + privateData + .aci + .credentialHolderInformation + .idCredSecret + .let(BLSSecretKey::from) + } + + else -> { + error("Keys needed for identity proof can't be neither derived nor decrypted") + } + } + + val ipInfo = + identityProvider + .ipInfo + .toSdkIdentityProviderInfo() + + val arsInfos = + identityProvider + .arsInfos + .mapValues { (_, it) -> it.toSdkAnonymityRevokerInfo() } + + val identityObject = + identity + .identityObject!! + .toSdkIdentityObject() + + return@withContext identityClaims.getIdentityProofInput( + network, + ipInfo, + arsInfos, + identityObject, + idCredSec, + prfKey, + signatureBlindingRandomness, + ) + } + + private suspend fun getAccountProofInput( + identityClaims: IdentityClaims, + account: Account, + identity: Identity, + password: String, + ): IdentityClaimsAccountProofInput = withContext(Dispatchers.Default) { + + val storageAccountDataEncrypted = account.encryptedAccountData + ?: error("Account has no encrypted data") + + val decryptedJson = App.appCore.auth + .decrypt( + password = password, + encryptedData = storageAccountDataEncrypted + ) + ?.let(::String) + ?: error("Failed to decrypt the account data") + + val ipIdentity = + identity + .identityProvider + .ipInfo + .ipIdentity + .let(UInt32::from) + + val credId = + account + .credential!! + .getCredId()!! + .let(CredentialRegistrationId::from) + + val attributeValues = + identity + .identityObject!! + .toSdkIdentityObject() + .attributeList + .chosenAttributes + + val attributeRandomness: Map = + App.appCore.gson + .fromJson(decryptedJson, StorageAccountData::class.java) + .getAttributeRandomness() + ?.attributesRand + ?.mapKeys { (typeString, _) -> + AttributeType.fromJSON(typeString) + } + ?: error("Attribute randomness is not available for the account") + + return@withContext identityClaims.getAccountProofInput( + network, + ipIdentity, + credId, + attributeValues, + attributeRandomness, + ) + } + + fun onChangeAccountClicked( + claimIndex: Int, + availableAccounts: Collection, + ) { + val claims = identityClaims[claimIndex] + val suitableAccounts = + availableAccounts + .filter { account -> + claims.canBeProvedBy(getIdentity(account)) + } + + if (suitableAccounts.size > 1) { + Log.d( + "Switching to account selection:" + + "\nsuitableAccounts=${availableAccounts.size}" + ) + + emitState( + State.AccountSelection( + accounts = suitableAccounts, + appMetadata = appMetadata, + previousState = createIdentityProofRequestState(claimIndex), + ) + ) + } else { + Log.w("No accounts to select from") + } + } + + fun onAccountSelected( + claimIndex: Int, + account: Account, + ) { + credentialsByClaims[identityClaims[claimIndex]] = + IdentityProofRequestSelectedCredential.Account( + account = account, + identity = getIdentity(account) + ) + + emitState( + createIdentityProofRequestState(claimIndex) + ) + } + + fun onChangeIdentityClicked( + claimIndex: Int, + ) { + val claims = identityClaims[claimIndex] + + val suitableIdentities = + identitiesById + .values + .filter { claims.canBeProvedBy(it) } + + if (suitableIdentities.size > 1) { + Log.d( + "Switching to identity selection:" + + "\nsuitableIdentities=${suitableIdentities.size}" + ) + + emitState( + State.IdentitySelection( + identities = suitableIdentities, + previousState = createIdentityProofRequestState(claimIndex), + ) + ) + } else { + Log.w("No identities to select from") + } + } + + fun onIdentitySelected( + claimIndex: Int, + identity: Identity, + ) { + credentialsByClaims[identityClaims[claimIndex]] = + IdentityProofRequestSelectedCredential.Identity(identity) + + emitState( + createIdentityProofRequestState(claimIndex) + ) + } + + private fun createIdentityProofRequestState( + currentClaimIndex: Int, + ): State.SessionRequestReview = + State.SessionRequestReview.IdentityProofRequestReview( + connectedAccount = connectedAccount, + appMetadata = appMetadata, + claims = + identityClaims + .map { claims -> + IdentityProofRequestClaims( + statements = claims.statements, + selectedCredential = credentialsByClaims.getValue(claims), + ) + }, + currentClaim = currentClaimIndex, + provable = identityProofProvableState, + isV1 = true, + ) + + private fun getIdentity(account: Account) = + identitiesById.getValue(account.identityId) + + private fun IdentityClaims.canBeProvedBy(identity: Identity): Boolean = + canBeProvedBy( + identity.identityObject!!.toSdkIdentityObject(), + UInt32.from(identity.identityProviderId) + ) + + companion object { + const val METHOD = "request_verifiable_presentation_v1" + } +} + +private typealias LoadedData = Pair diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectView.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectView.kt index 70d4f9f5..8e103287 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectView.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectView.kt @@ -18,10 +18,10 @@ import androidx.viewpager2.widget.ViewPager2.INVISIBLE import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import androidx.viewpager2.widget.ViewPager2.VISIBLE import com.bumptech.glide.Glide -import com.concordium.sdk.crypto.wallet.web3Id.Statement.RequestStatement import com.concordium.wallet.R import com.concordium.wallet.data.model.Token import com.concordium.wallet.data.room.Account +import com.concordium.wallet.data.room.Identity import com.concordium.wallet.data.util.CurrencyUtil import com.concordium.wallet.databinding.FragmentWalletConnectAccountSelectionBinding import com.concordium.wallet.databinding.FragmentWalletConnectIdentityProofRequestReviewBinding @@ -112,6 +112,12 @@ class WalletConnectView( ) } + is WalletConnectViewModel.State.IdentitySelection -> { + showIdentitySelection( + identities = state.identities, + ) + } + WalletConnectViewModel.State.WaitingForSessionRequest -> { showConnecting() } @@ -141,10 +147,9 @@ class WalletConnectView( is WalletConnectViewModel.State.SessionRequestReview.IdentityProofRequestReview -> { showIdentityProofRequestReview( - accounts = state.chosenAccounts, appMetadata = state.appMetadata, - statements = state.request.credentialStatements, - currentStatement = state.currentStatement, + claims = state.claims, + currentClaim = state.currentClaim, provableState = state.provable ) } @@ -314,6 +319,27 @@ class WalletConnectView( accountsListView.adapter = adapter } + private fun showIdentitySelection( + identities: List, + ) { + getShownBottomSheet().showIdentitySelection { (view, _) -> + initIdentitySelectionView( + view = view, + identities = identities, + ) + } + } + + private fun initIdentitySelectionView( + view: FragmentWalletConnectAccountSelectionBinding, + identities: List, + ) = with(view) { + titleTextView.setText(R.string.wallet_connect_choose_another_identity) + val adapter = ChooseIdentityListAdapter(root.context, identities) + adapter.setOnClickListener(viewModel::onIdentitySelected) + accountsListView.adapter = adapter + } + private fun showTransactionRequestReview( method: String, receiver: String, @@ -520,20 +546,18 @@ class WalletConnectView( } private fun showIdentityProofRequestReview( - statements: List, - accounts: List, + claims: List, appMetadata: WalletConnectViewModel.AppMetadata, - currentStatement: Int, + currentClaim: Int, provableState: WalletConnectViewModel.ProofProvableState, ) { getShownBottomSheet().showIdentityProofRequestReview { (view, lifecycleOwner) -> initIdentityProofRequestReview( view = view, lifecycleOwner = lifecycleOwner, - accounts = accounts, appMetadata = appMetadata, - statements = statements, - currentStatement = currentStatement, + claims = claims, + currentClaim = currentClaim, provableState = provableState ) } @@ -542,10 +566,9 @@ class WalletConnectView( private fun initIdentityProofRequestReview( view: FragmentWalletConnectIdentityProofRequestReviewBinding, lifecycleOwner: LifecycleOwner, - accounts: List, appMetadata: WalletConnectViewModel.AppMetadata, - statements: List, - currentStatement: Int, + claims: List, + currentClaim: Int, provableState: WalletConnectViewModel.ProofProvableState, ) = with(view) { Glide.with(appIconImageView.context) @@ -592,19 +615,18 @@ class WalletConnectView( } val adapter = CredentialStatementAdapter( - statements = statements, - accounts = accounts, - getIdentity = viewModel::getIdentity, + claims = claims, onChangeAccountClicked = viewModel::onChangeIdentityProofAccountClicked, + onIdentityChangeClicked = viewModel::onChangeIdentityProofIdentityClicked, ) fun updatePrimaryActionButton() { val currentPosition = proofView.currentItem - approveButton.isInvisible = currentPosition < statements.size - 1 + approveButton.isInvisible = currentPosition < claims.size - 1 nextButton.isVisible = approveButton.isInvisible } this.proofView.adapter = adapter - this.proofView.setCurrentItem(currentStatement, false) + this.proofView.setCurrentItem(currentClaim, false) this.proofView.registerOnPageChangeCallback(object : OnPageChangeCallback() { override fun onPageSelected(position: Int) { updatePrimaryActionButton() diff --git a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectViewModel.kt b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectViewModel.kt index c8f86e08..94b509ae 100644 --- a/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectViewModel.kt +++ b/app/src/main/java/com/concordium/wallet/ui/walletconnect/WalletConnectViewModel.kt @@ -7,7 +7,6 @@ import android.net.Uri import androidx.core.net.toUri import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope -import com.concordium.sdk.crypto.wallet.web3Id.UnqualifiedRequest import com.concordium.wallet.App import com.concordium.wallet.BuildConfig import com.concordium.wallet.R @@ -17,13 +16,9 @@ import com.concordium.wallet.data.IdentityRepository import com.concordium.wallet.data.backend.repository.ProxyRepository import com.concordium.wallet.data.cryptolib.StorageAccountData import com.concordium.wallet.data.model.Token -import com.concordium.wallet.data.model.TransactionType import com.concordium.wallet.data.room.Account import com.concordium.wallet.data.room.Identity import com.concordium.wallet.extension.collect -import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.Companion.REQUEST_METHOD_SIGN_AND_SEND_TRANSACTION -import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.Companion.REQUEST_METHOD_SIGN_MESSAGE -import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.Companion.REQUEST_METHOD_VERIFIABLE_PRESENTATION import com.concordium.wallet.ui.walletconnect.WalletConnectViewModel.State import com.concordium.wallet.ui.walletconnect.delegate.LoggingWalletConnectCoreDelegate import com.concordium.wallet.ui.walletconnect.delegate.LoggingWalletConnectWalletDelegate @@ -55,17 +50,14 @@ import java.math.BigInteger * *Session request* – a request from a dApp within an active session that requires performing * some action with the account of the session. * - * *go_back* - whether to navigate back after handling the URI - * * Currently supported requests: - * - [REQUEST_METHOD_SIGN_AND_SEND_TRANSACTION] – create, sign and submit an account transaction + * - [WalletConnectSignTransactionRequestHandler.METHOD] – create, sign and submit an account transaction * of the requested type. Send back the submission ID; - * - [REQUEST_METHOD_SIGN_MESSAGE] – sign the given text message. Send back the signature; - * - [REQUEST_METHOD_VERIFIABLE_PRESENTATION] - prove or reveal identity statements. + * - [WalletConnectSignMessageRequestHandler.METHOD] – sign the given text message. Send back the signature; + * - [WalletConnectVerifiablePresentationRequestHandler.METHOD] - prove or reveal identity statements; + * - [WalletConnectVerifiablePresentationV1RequestHandler.METHOD] - prove or reveal identity statements via auditable proofs. * * @see State - * @see allowedAccountTransactionTypes - * @see goBack */ class WalletConnectViewModel private constructor( @@ -98,9 +90,10 @@ private constructor( "${application.getString(R.string.wc_scheme)}:", ) private val allowedRequestMethods = setOf( - REQUEST_METHOD_SIGN_AND_SEND_TRANSACTION, - REQUEST_METHOD_SIGN_MESSAGE, - REQUEST_METHOD_VERIFIABLE_PRESENTATION, + WalletConnectSignTransactionRequestHandler.METHOD, + WalletConnectSignMessageRequestHandler.METHOD, + WalletConnectVerifiablePresentationRequestHandler.METHOD, + WalletConnectVerifiablePresentationV1RequestHandler.METHOD, ) private val accountRepository: AccountRepository by lazy { @@ -114,7 +107,6 @@ private constructor( private lateinit var sessionProposalPublicKey: String private lateinit var sessionProposalNamespaceKey: String - private lateinit var sessionProposalNamespaceChain: String private lateinit var sessionProposalNamespace: Sign.Model.Namespace.Proposal private var sessionRequestId: Long = 0L @@ -138,8 +130,7 @@ private constructor( private val mutableEventsFlow = MutableSharedFlow(extraBufferCapacity = 10) val eventsFlow: Flow = mutableEventsFlow - private val mutableIsSubmittingTransactionFlow = MutableStateFlow(false) - private val isBusyFlow: Flow = mutableIsSubmittingTransactionFlow + private val isBusyFlow = MutableStateFlow(false) val isSessionRequestApproveButtonEnabledFlow: Flow = isBusyFlow.combine(stateFlow) { isBusy, state -> !isBusy && state is State.SessionRequestReview && state.canApprove @@ -153,7 +144,7 @@ private constructor( emitEvent = mutableEventsFlow::tryEmit, emitState = mutableStateFlow::tryEmit, onFinish = ::onSessionRequestHandlingFinished, - setIsSubmittingTransaction = mutableIsSubmittingTransactionFlow::tryEmit, + setIsSubmittingTransaction = isBusyFlow::tryEmit, proxyRepository = proxyRepository, tokensInteractor = tokensInteractor, context = application, @@ -184,6 +175,21 @@ private constructor( ) } + private val verifiablePresentationV1RequestHandler: WalletConnectVerifiablePresentationV1RequestHandler by lazy { + WalletConnectVerifiablePresentationV1RequestHandler( + respondSuccess = ::respondSuccess, + respondError = ::respondError, + emitEvent = mutableEventsFlow::tryEmit, + emitState = mutableStateFlow::tryEmit, + onFinish = ::onSessionRequestHandlingFinished, + setIsLoading = isBusyFlow::tryEmit, + proxyRepository = proxyRepository, + identityRepository = identityRepository, + walletSetupPreferences = App.appCore.session.walletStorage.setupPreferences, + activeWalletType = App.appCore.session.activeWallet.type, + ) + } + init { stateFlow.collect(viewModelScope) { state -> Log.d( @@ -352,22 +358,17 @@ private constructor( ) = viewModelScope.launch { defaultWalletDelegate.onSessionProposal(sessionProposal, verifyContext) - // Find a single allowed namespace and chain. + // Find a single acceptable namespace. val singleNamespaceEntry = (sessionProposal.requiredNamespaces.entries + sessionProposal.optionalNamespaces.entries) .find { (_, namespace) -> - namespace.chains?.any { chain -> - allowedChains.contains(chain) - } == true + allowedChains.containsAll(namespace.chains.orEmpty()) } - val singleNamespaceChain = singleNamespaceEntry?.value?.chains?.find { chain -> - allowedChains.contains(chain) - } val proposerPublicKey = sessionProposal.proposerPublicKey - if (singleNamespaceEntry == null || singleNamespaceChain == null) { - Log.e("cant_find_supported_chain") + if (singleNamespaceEntry == null) { + Log.e("cant_find_supported_chains") mutableEventsFlow.tryEmit( Event.ShowFloatingError( Error.NoSupportedChains @@ -375,7 +376,8 @@ private constructor( ) rejectSession( proposerPublicKey, - "The session proposal did not contain a valid namespace. Allowed namespaces are: $allowedChains" + "The proposal did not contain a namespace where all chains are supported. " + + "Supported chains are: $allowedChains" ) return@launch } @@ -417,7 +419,6 @@ private constructor( this@WalletConnectViewModel.sessionProposalPublicKey = proposerPublicKey this@WalletConnectViewModel.sessionProposalNamespaceKey = singleNamespaceEntry.key this@WalletConnectViewModel.sessionProposalNamespace = singleNamespaceEntry.value - this@WalletConnectViewModel.sessionProposalNamespaceChain = singleNamespaceChain // Initially select the account with the biggest balance. val initiallySelectedAccount = accounts.maxBy { account -> @@ -453,8 +454,10 @@ private constructor( proposerPublicKey = sessionProposalPublicKey, namespaces = mapOf( sessionProposalNamespaceKey to Sign.Model.Namespace.Session( - chains = listOf(sessionProposalNamespaceChain), - accounts = listOf("$sessionProposalNamespaceChain:$accountAddress"), + chains = sessionProposalNamespace.chains, + accounts = sessionProposalNamespace.chains!!.map { chain -> + "$chain:$accountAddress" + }, methods = sessionProposalNamespace.methods, events = sessionProposalNamespace.events, ) @@ -516,10 +519,9 @@ private constructor( mutableStateFlow.emit( State.AccountSelection( - selectedAccount = reviewState.selectedAccount, accounts = accounts, appMetadata = reviewState.appMetadata, - identityProofPosition = null + previousState = reviewState, ) ) } @@ -530,36 +532,88 @@ private constructor( "The account can only be selected in the account selection state" } - Log.d( - "switching_to_session_proposal_review:" + - "\nnewSelectedAccountId=${selectedAccount.id}" - ) - - if (!selectionState.forIdentityProof) { - mutableStateFlow.tryEmit( - State.SessionProposalReview( - selectedAccount = selectedAccount, - appMetadata = selectionState.appMetadata + when (val previousState = selectionState.previousState) { + is State.SessionProposalReview -> + mutableStateFlow.tryEmit( + State.SessionProposalReview( + selectedAccount = selectedAccount, + appMetadata = selectionState.appMetadata + ) ) + + is State.SessionRequestReview.IdentityProofRequestReview -> + if (previousState.isV1) { + verifiablePresentationV1RequestHandler.onAccountSelected( + claimIndex = previousState.currentClaim, + account = selectedAccount, + ) + } else { + verifiablePresentationRequestHandler.onAccountSelected( + statementIndex = previousState.currentClaim, + account = selectedAccount, + ) + } + + else -> + error("Nothing to do with the selected account") + } + } + + fun onIdentitySelected(selectedIdentity: Identity) { + val selectionState = checkNotNull(state as? State.IdentitySelection) { + "The identity can only be selected in the identity selection state" + } + + when (val previousState = selectionState.previousState) { + is State.SessionRequestReview.IdentityProofRequestReview -> + if (previousState.isV1) { + verifiablePresentationV1RequestHandler.onIdentitySelected( + claimIndex = previousState.currentClaim, + identity = selectedIdentity, + ) + } else { + error("Identity can't be changed for V0 proof request") + } + + else -> + error("Nothing to do with the selected identity") + } + } + + fun onChangeIdentityProofAccountClicked( + statementIndex: Int, + ) = viewModelScope.launch { + val proofReviewState = state + check(proofReviewState is State.SessionRequestReview.IdentityProofRequestReview) { + "Choose account button can only be clicked in the proof request review state" + } + + if (proofReviewState.isV1) { + verifiablePresentationV1RequestHandler.onChangeAccountClicked( + claimIndex = statementIndex, + availableAccounts = getAvailableAccounts(), ) } else { - verifiablePresentationRequestHandler.onAccountSelected( - statementIndex = selectionState.identityProofPosition!!, - account = selectedAccount, + verifiablePresentationRequestHandler.onChangeAccountClicked( + statementIndex = statementIndex, + availableAccounts = getAvailableAccounts(), ) } } - fun onChangeIdentityProofAccountClicked( + fun onChangeIdentityProofIdentityClicked( statementIndex: Int, ) = viewModelScope.launch { - check(state is State.SessionRequestReview.IdentityProofRequestReview) { - "Choose account button can only be clicked in the proof request review state" + val proofReviewState = state + check( + proofReviewState is State.SessionRequestReview.IdentityProofRequestReview + && proofReviewState.isV1 + ) { + "Choose identity button can only be clicked in the proof request V1 review state" } - verifiablePresentationRequestHandler.onChangeAccountClicked( - statementIndex = statementIndex, - availableAccounts = getAvailableAccounts(), + verifiablePresentationV1RequestHandler.onChangeIdentityClicked( + claimIndex = statementIndex, ) } @@ -715,21 +769,21 @@ private constructor( // if there is an unexpected error. try { when (method) { - REQUEST_METHOD_SIGN_AND_SEND_TRANSACTION -> + WalletConnectSignTransactionRequestHandler.METHOD -> signTransactionRequestHandler.start( params = params, account = sessionRequestAccount, appMetadata = sessionRequestAppMetadata, ) - REQUEST_METHOD_SIGN_MESSAGE -> + WalletConnectSignMessageRequestHandler.METHOD -> signMessageRequestHandler.start( params = params, account = sessionRequestAccount, appMetadata = sessionRequestAppMetadata, ) - REQUEST_METHOD_VERIFIABLE_PRESENTATION -> { + WalletConnectVerifiablePresentationRequestHandler.METHOD -> { verifiablePresentationRequestHandler.start( params = params, account = account, @@ -738,6 +792,15 @@ private constructor( ) } + WalletConnectVerifiablePresentationV1RequestHandler.METHOD -> { + verifiablePresentationV1RequestHandler.start( + params = params, + connectedAccount = account, + availableAccounts = getAvailableAccounts(), + appMetadata = sessionRequestAppMetadata, + ) + } + else -> error("Missing a handler for the allowed method '$method'") } @@ -760,6 +823,7 @@ private constructor( private fun onSessionRequestHandlingFinished() { mutableStateFlow.tryEmit(State.Idle) + isBusyFlow.tryEmit(false) if (!handleGoBack()) { handleNextOldestPendingSessionRequest() } @@ -886,7 +950,11 @@ private constructor( signTransactionRequestHandler.onAuthorizedForApproval(accountKeys) is State.SessionRequestReview.IdentityProofRequestReview -> { - verifiablePresentationRequestHandler.onAuthorizedForApproval(password) + if (reviewState.isV1) { + verifiablePresentationV1RequestHandler.onAuthorizedForApproval(password) + } else { + verifiablePresentationRequestHandler.onAuthorizedForApproval(password) + } } } } else { @@ -900,9 +968,6 @@ private constructor( } } - fun getIdentity(account: Account): Identity? = - verifiablePresentationRequestHandler.getIdentity(account) - fun getIdentityFromRepository(account: Account) = runBlocking(Dispatchers.IO) { identityRepository.getAllDone().firstOrNull { it.id == account.identityId } } @@ -940,7 +1005,15 @@ private constructor( } is State.AccountSelection -> { - if ((state as State.AccountSelection).forIdentityProof) { + if ((state as State.AccountSelection).previousState is State.SessionRequestReview) { + rejectSessionRequest() + } else { + rejectSessionProposal() + } + } + + is State.IdentitySelection -> { + if ((state as State.IdentitySelection).previousState is State.SessionRequestReview) { rejectSessionRequest() } else { rejectSessionProposal() @@ -975,18 +1048,7 @@ private constructor( "switching_back_from_account_selection" ) - if (state.forIdentityProof) { - verifiablePresentationRequestHandler.onAccountSelectionBackPressed( - statementIndex = state.identityProofPosition!!, - ) - } else { - mutableStateFlow.tryEmit( - State.SessionProposalReview( - selectedAccount = state.selectedAccount, - appMetadata = state.appMetadata, - ) - ) - } + mutableStateFlow.tryEmit(state.previousState) true } @@ -1102,9 +1164,9 @@ private constructor( | | | | | | | | | | v | - | +------------> SessionRequestReview <-----------------+ - | | - | | + | +------------> SessionRequestReview <-----------------+ IdentitySelection + | | ^ ^ + | | +-------------------------------------------+ | v +--------------- TransactionSubmitted ``` @@ -1136,13 +1198,19 @@ private constructor( * in order to continue. */ class AccountSelection( - val selectedAccount: Account, val accounts: List, - val identityProofPosition: Int?, val appMetadata: AppMetadata, - ) : State { - val forIdentityProof = identityProofPosition != null - } + val previousState: State, + ) : State + + /** + * The user must select an identity among the available + * in order to continue. + */ + class IdentitySelection( + val identities: List, + val previousState: State, + ) : State /** * Explicitly waiting for a session request after receiving a request URI. @@ -1186,10 +1254,10 @@ private constructor( ) class IdentityProofRequestReview( - val request: UnqualifiedRequest, - val chosenAccounts: List, - val currentStatement: Int, + val claims: List, + val currentClaim: Int, val provable: ProofProvableState, + val isV1: Boolean, connectedAccount: Account, appMetadata: AppMetadata, ) : SessionRequestReview( @@ -1312,12 +1380,7 @@ private constructor( private companion object { private const val WC_URI_PREFIX = "wc:" private const val WC_URI_REQUEST_ID_PARAM = "requestId" - private const val DEFAULT_ERROR_RESPONSE_CODE = 500 - - private const val REQUEST_METHOD_SIGN_AND_SEND_TRANSACTION = "sign_and_send_transaction" - private const val REQUEST_METHOD_SIGN_MESSAGE = "sign_message" - private const val REQUEST_METHOD_VERIFIABLE_PRESENTATION = "request_verifiable_presentation" private const val WC_GO_BACK_PARAM = "go_back=true" } } diff --git a/app/src/main/res/layout/identity_proof_container.xml b/app/src/main/res/layout/identity_proof_container.xml index 58d16b65..35355e0b 100644 --- a/app/src/main/res/layout/identity_proof_container.xml +++ b/app/src/main/res/layout/identity_proof_container.xml @@ -6,13 +6,13 @@ android:layout_height="0dp" android:fillViewport="true"> - @@ -30,8 +30,7 @@ android:id="@+id/statements" android:layout_width="match_parent" android:layout_height="wrap_content" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@id/selected_account_include_container" /> - + app:layout_constraintStart_toStartOf="parent" /> + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba38dc32..a5d6722e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -522,6 +522,7 @@ An internal error occurred Only seed phrase wallets support this feature Choose another account + Choose another identity Connect to %1$s? @string/allow @string/decline