From 456267859e43cbaa35a3ab27b852ab42409fecdc Mon Sep 17 00:00:00 2001 From: Marco Martinez Date: Fri, 6 Oct 2023 12:42:29 -0600 Subject: [PATCH] MWA 2.0 Library RPC Changes (#560) * auth/reauth refactor pass 1 * get_capabilities refactor * chains + account metadata refactor * revert fakewallet changes (apps will updated separately) * derp --- .../protocol/MobileWalletAdapterClient.java | 178 ++++++++++++++---- .../common/ProtocolContract.java | 33 +++- .../common/util/Identifier.java | 35 ++++ .../walletlib/authorization/AuthRecord.java | 10 +- .../protocol/MobileWalletAdapterConfig.java | 41 ++++ .../protocol/MobileWalletAdapterServer.java | 87 ++++++--- .../walletlib/scenario/AuthorizeRequest.java | 39 ++-- .../walletlib/scenario/AuthorizedAccount.java | 42 +++++ .../BaseVerifiableIdentityRequest.java | 15 +- .../walletlib/scenario/LocalScenario.java | 89 ++++++++- .../LocalWebSocketServerScenarioTest.java | 8 +- 11 files changed, 484 insertions(+), 93 deletions(-) create mode 100644 android/common/src/main/java/com/solana/mobilewalletadapter/common/util/Identifier.java create mode 100644 android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizedAccount.java diff --git a/android/clientlib/src/main/java/com/solana/mobilewalletadapter/clientlib/protocol/MobileWalletAdapterClient.java b/android/clientlib/src/main/java/com/solana/mobilewalletadapter/clientlib/protocol/MobileWalletAdapterClient.java index 370da39b2..e86b98647 100644 --- a/android/clientlib/src/main/java/com/solana/mobilewalletadapter/clientlib/protocol/MobileWalletAdapterClient.java +++ b/android/clientlib/src/main/java/com/solana/mobilewalletadapter/clientlib/protocol/MobileWalletAdapterClient.java @@ -11,18 +11,23 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.Size; +import androidx.annotation.VisibleForTesting; import com.solana.mobilewalletadapter.clientlib.transaction.TransactionVersion; import com.solana.mobilewalletadapter.common.ProtocolContract; +import com.solana.mobilewalletadapter.common.util.Identifier; import com.solana.mobilewalletadapter.common.util.JsonPack; import com.solana.mobilewalletadapter.common.util.NotifyOnCompleteFuture; +import org.jetbrains.annotations.TestOnly; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -132,21 +137,23 @@ public String toString() { public static class AuthorizationResult { @NonNull public final String authToken; - @NonNull + @Deprecated @NonNull public final byte[] publicKey; - @Nullable + @Deprecated @Nullable public final String accountLabel; @Nullable public final Uri walletUriBase; + @NonNull @Size(min = 1) + public final AuthorizedAccount[] accounts; private AuthorizationResult(@NonNull String authToken, - @NonNull byte[] publicKey, - @Nullable String accountLabel, + @NonNull @Size(min = 1) AuthorizedAccount[] accounts, @Nullable Uri walletUriBase) { this.authToken = authToken; - this.publicKey = publicKey; - this.accountLabel = accountLabel; this.walletUriBase = walletUriBase; + this.accounts = accounts; + this.publicKey = accounts[0].publicKey; + this.accountLabel = accounts[0].accountLabel; } @NonNull @@ -154,19 +161,61 @@ private AuthorizationResult(@NonNull String authToken, public String toString() { return "AuthorizationResult{" + "authToken=" + - ", publicKey=" + Arrays.toString(publicKey) + - ", accountLabel='" + accountLabel + '\'' + ", walletUriBase=" + walletUriBase + + ", accounts=" + Arrays.toString(accounts) + '}'; } + public static class AuthorizedAccount { + @NonNull + public final byte[] publicKey; + @Nullable + public final String accountLabel; + @Nullable + public final String[] chains; + @Nullable + public final String[] features; + + private AuthorizedAccount(@NonNull byte[] publicKey, + @Nullable String accountLabel, + @Nullable String[] chains, + @Nullable String[] features) { + this.publicKey = publicKey; + this.accountLabel = accountLabel; + this.chains = chains; + this.features = features; + } + + @NonNull + @Override + public String toString() { + return "AuthorizedAccount{" + + "publicKey=" + Arrays.toString(publicKey) + + ", accountLabel='" + accountLabel + '\'' + + ", chains=" + Arrays.toString(chains) + + ", features=" + Arrays.toString(features) + + '}'; + } + } + + @Deprecated @TestOnly @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) public static AuthorizationResult create( String authToken, byte[] publicKey, String accountLabel, Uri walletUriBase ) { - return new AuthorizationResult(authToken, publicKey, accountLabel, walletUriBase); + AuthorizedAccount[] accounts = new AuthorizedAccount[] { new AuthorizedAccount(publicKey, accountLabel, null, null) }; + return new AuthorizationResult(authToken, accounts, walletUriBase); + } + + @TestOnly @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + public static AuthorizationResult create( + String authToken, + AuthorizedAccount[] accounts, + Uri walletUriBase + ) { + return new AuthorizationResult(authToken, accounts, walletUriBase); } } @@ -194,17 +243,40 @@ protected AuthorizationResult processResult(@Nullable Object o) throw new JsonRpc20InvalidResponseException("expected an auth_token"); } - final byte[] publicKey; - final String accountLabel; + final AuthorizationResult.AuthorizedAccount[] authorizedAccounts; try { final JSONArray accounts = jo.getJSONArray(ProtocolContract.RESULT_ACCOUNTS); - final JSONObject account = accounts.getJSONObject(0); // TODO(#44): support multiple addresses - final String b64EncodedAddress = account.getString(ProtocolContract.RESULT_ACCOUNTS_ADDRESS); - publicKey = JsonPack.unpackBase64PayloadToByteArray(b64EncodedAddress); - if (account.has(ProtocolContract.RESULT_ACCOUNTS_LABEL)) { - accountLabel = account.getString(ProtocolContract.RESULT_ACCOUNTS_LABEL); - } else { - accountLabel = null; + authorizedAccounts = new AuthorizationResult.AuthorizedAccount[accounts.length()]; + for (int i = 0; i < accounts.length(); i++) { + final byte[] publicKey; + final String accountLabel; + final String[] chains; + final String[] features; + final JSONObject account = accounts.getJSONObject(i); + final String b64EncodedAddress = account.getString(ProtocolContract.RESULT_ACCOUNTS_ADDRESS); + publicKey = JsonPack.unpackBase64PayloadToByteArray(b64EncodedAddress); + if (account.has(ProtocolContract.RESULT_ACCOUNTS_LABEL)) { + accountLabel = account.getString(ProtocolContract.RESULT_ACCOUNTS_LABEL); + } else { + accountLabel = null; + } + final JSONArray chainsArr = account.optJSONArray(ProtocolContract.RESULT_ACCOUNTS_CHAINS); + if (chainsArr == null) chains = null; + else { + chains = new String[chainsArr.length()]; + for (int c = 0; c < chainsArr.length(); c++) { + chains[c] = chainsArr.getString(0); + } + } + final JSONArray featuresArr = account.optJSONArray(ProtocolContract.RESULT_SUPPORTED_FEATURES); + if (featuresArr == null) features = null; + else { + features = new String[featuresArr.length()]; + for (int c = 0; c < featuresArr.length(); c++) { + features[c] = featuresArr.getString(0); + } + } + authorizedAccounts[i] = new AuthorizationResult.AuthorizedAccount(publicKey, accountLabel, chains, features); } } catch (JSONException e) { throw new JsonRpc20InvalidResponseException("expected one or more addresses"); @@ -218,7 +290,7 @@ protected AuthorizationResult processResult(@Nullable Object o) walletUriBaseStr + "'; expected a 'https' URI"); } - return new AuthorizationResult(authToken, publicKey, accountLabel, walletUriBase); + return new AuthorizationResult(authToken, authorizedAccounts, walletUriBase); } @Override @@ -239,7 +311,7 @@ public static class InsecureWalletEndpointUriException extends JsonRpc20InvalidR public AuthorizationFuture authorize(@Nullable Uri identityUri, @Nullable Uri iconUri, @Nullable String identityName, - @Nullable String cluster) + @Nullable String chain) throws IOException { if (identityUri != null && (!identityUri.isAbsolute() || !identityUri.isHierarchical())) { throw new IllegalArgumentException("If non-null, identityUri must be an absolute, hierarchical Uri"); @@ -255,7 +327,7 @@ public AuthorizationFuture authorize(@Nullable Uri identityUri, identity.put(ProtocolContract.PARAMETER_IDENTITY_NAME, identityName); authorize = new JSONObject(); authorize.put(ProtocolContract.PARAMETER_IDENTITY, identity); - authorize.put(ProtocolContract.PARAMETER_CLUSTER, cluster); // null is OK + authorize.put(ProtocolContract.PARAMETER_CHAIN, chain); // null is OK } catch (JSONException e) { throw new UnsupportedOperationException("Failed to create authorize JSON params", e); } @@ -345,8 +417,9 @@ public GetCapabilitiesFuture getCapabilities() } public static class GetCapabilitiesResult { + @Deprecated public final boolean supportsCloneAuthorization; - + @Deprecated public final boolean supportsSignAndSendTransactions; @IntRange(from = 0) @@ -359,16 +432,31 @@ public static class GetCapabilitiesResult { @Size(min = 1) public final Object[] supportedTransactionVersions; - private GetCapabilitiesResult(boolean supportsCloneAuthorization, - boolean supportsSignAndSendTransactions, - @IntRange(from = 0) int maxTransactionsPerSigningRequest, + @NonNull + public final String[] supportedOptionalFeatures; + + private GetCapabilitiesResult(@IntRange(from = 0) int maxTransactionsPerSigningRequest, @IntRange(from = 0) int maxMessagesPerSigningRequest, - @NonNull @Size(min = 1) Object[] supportedTransactionVersions) { - this.supportsCloneAuthorization = supportsCloneAuthorization; - this.supportsSignAndSendTransactions = supportsSignAndSendTransactions; + @NonNull @Size(min = 1) Object[] supportedTransactionVersions, + @NonNull String[] supportedFeatures) { this.maxTransactionsPerSigningRequest = maxTransactionsPerSigningRequest; this.maxMessagesPerSigningRequest = maxMessagesPerSigningRequest; this.supportedTransactionVersions = supportedTransactionVersions; + this.supportedOptionalFeatures = supportedFeatures; + + boolean supportsCloneAuthorization = false; + boolean supportsSignAndSendTransactions = false; + for (String featureId : supportedFeatures) { + if (featureId == null) continue; + if (featureId.equals(ProtocolContract.FEATURE_ID_SIGN_AND_SEND_TRANSACTIONS)) { + supportsSignAndSendTransactions = true; + } + if (featureId.equals(ProtocolContract.FEATURE_ID_CLONE_AUTHORIZATION)) { + supportsCloneAuthorization = true; + } + } + this.supportsCloneAuthorization = supportsCloneAuthorization; + this.supportsSignAndSendTransactions = supportsSignAndSendTransactions; } @NonNull @@ -380,6 +468,7 @@ public String toString() { ", maxTransactionsPerSigningRequest=" + maxTransactionsPerSigningRequest + ", maxMessagesPerSigningRequest=" + maxMessagesPerSigningRequest + ", supportedTransactionVersions=" + Arrays.toString(supportedTransactionVersions) + + ", supportedOptionalFeatures=" + Arrays.toString(supportedOptionalFeatures) + '}'; } } @@ -406,9 +495,10 @@ protected GetCapabilitiesResult processResult(@Nullable Object o) final int maxTransactionsPerSigningRequest; final int maxMessagesPerSigningRequest; final Object[] supportedTransactionVersions; + final String[] supportedOptionalFeatures; try { - supportsCloneAuthorization = jo.getBoolean(ProtocolContract.RESULT_SUPPORTS_CLONE_AUTHORIZATION); - supportsSignAndSendTransactions = jo.getBoolean(ProtocolContract.RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS); + supportsCloneAuthorization = jo.optBoolean(ProtocolContract.RESULT_SUPPORTS_CLONE_AUTHORIZATION); + supportsSignAndSendTransactions = jo.optBoolean(ProtocolContract.RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS); maxTransactionsPerSigningRequest = jo.optInt(ProtocolContract.RESULT_MAX_TRANSACTIONS_PER_REQUEST, 0); maxMessagesPerSigningRequest = jo.optInt(ProtocolContract.RESULT_MAX_MESSAGES_PER_REQUEST, 0); @@ -430,15 +520,35 @@ protected GetCapabilitiesResult processResult(@Nullable Object o) // transactions are supported. supportedTransactionVersions = new Object[] { TransactionVersion.LEGACY }; } + + final JSONArray supportedOptionalFeaturesArr = jo.optJSONArray(ProtocolContract.RESULT_SUPPORTED_FEATURES); + if (supportedOptionalFeaturesArr != null) { + final int length = supportedOptionalFeaturesArr.length(); + supportedOptionalFeatures = new String[length]; + for (int i = 0; i < length; i++) { + final String sof = supportedOptionalFeaturesArr.getString(i); + if (!Identifier.isValidIdentifier(sof)) { + throw new JSONException("features expected to contain only valid namespaced feature identifiers (String)"); + } + supportedOptionalFeatures[i] = sof; + } + } else { + // Previous versions of the Mobile Wallet Adapter protocol spec used explicit + // parameters for optional features. Map the old feature support parameters to + // the new optional features array + List supportedOptionalFeaturesList = new ArrayList<>(); + if (supportsCloneAuthorization) supportedOptionalFeaturesList.add(ProtocolContract.RESULT_SUPPORTS_CLONE_AUTHORIZATION); + if (supportsSignAndSendTransactions) supportedOptionalFeaturesList.add(ProtocolContract.RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS); + supportedOptionalFeatures = supportedOptionalFeaturesList.toArray(new String[0]); + } } catch (JSONException e) { throw new JsonRpc20InvalidResponseException("result does not conform to expected format"); } - return new GetCapabilitiesResult(supportsCloneAuthorization, - supportsSignAndSendTransactions, - maxTransactionsPerSigningRequest, + return new GetCapabilitiesResult(maxTransactionsPerSigningRequest, maxMessagesPerSigningRequest, - supportedTransactionVersions); + supportedTransactionVersions, + supportedOptionalFeatures); } @Override diff --git a/android/common/src/main/java/com/solana/mobilewalletadapter/common/ProtocolContract.java b/android/common/src/main/java/com/solana/mobilewalletadapter/common/ProtocolContract.java index 8631fe57b..ec89ee98a 100644 --- a/android/common/src/main/java/com/solana/mobilewalletadapter/common/ProtocolContract.java +++ b/android/common/src/main/java/com/solana/mobilewalletadapter/common/ProtocolContract.java @@ -7,7 +7,8 @@ public class ProtocolContract { public static final String METHOD_AUTHORIZE = "authorize"; // METHOD_AUTHORIZE takes an optional PARAMETER_IDENTITY - public static final String PARAMETER_CLUSTER = "cluster"; // type: String (one of the CLUSTER_* values) + // METHOD_AUTHORIZE takes an optional PARAMETER_AUTH_TOKEN + // METHOD_AUTHORIZE takes an optional PARAMETER_CHAIN // METHOD_AUTHORIZE returns a RESULT_AUTH_TOKEN // METHOD_AUTHORIZE returns a RESULT_ACCOUNTS // METHOD_AUTHORIZE returns an optional RESULT_WALLET_URI_BASE @@ -15,6 +16,7 @@ public class ProtocolContract { public static final String METHOD_DEAUTHORIZE = "deauthorize"; // METHOD_DEAUTHORIZE takes a PARAMETER_AUTH_TOKEN + @Deprecated public static final String METHOD_REAUTHORIZE = "reauthorize"; // METHOD_REAUTHORIZE takes an optional PARAMETER_IDENTITY // METHOD_REAUTHORIZE takes a PARAMETER_AUTH_TOKEN @@ -26,11 +28,14 @@ public class ProtocolContract { // METHOD_CLONE_AUTHORIZATION returns a RESULT_AUTH_TOKEN public static final String METHOD_GET_CAPABILITIES = "get_capabilities"; - public static final String RESULT_SUPPORTS_CLONE_AUTHORIZATION = "supports_clone_authorization"; // type: Boolean - public static final String RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS = "supports_sign_and_send_transactions"; // type: Boolean public static final String RESULT_MAX_TRANSACTIONS_PER_REQUEST = "max_transactions_per_request"; // type: Number public static final String RESULT_MAX_MESSAGES_PER_REQUEST = "max_messages_per_request"; // type: Number public static final String RESULT_SUPPORTED_TRANSACTION_VERSIONS = "supported_transaction_versions"; // type: JSON array of any primitive datatype + public static final String RESULT_SUPPORTED_FEATURES = "features"; // type: JSON array of String (feature identifiers) + @Deprecated + public static final String RESULT_SUPPORTS_CLONE_AUTHORIZATION = "supports_clone_authorization"; // type: Boolean + @Deprecated + public static final String RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS = "supports_sign_and_send_transactions"; // type: Boolean public static final String METHOD_SIGN_TRANSACTIONS = "sign_transactions"; // METHOD_SIGN_TRANSACTIONS takes a PARAMETER_PAYLOADS @@ -52,6 +57,11 @@ public class ProtocolContract { public static final String PARAMETER_IDENTITY_ICON = "icon"; // type: String (relative URI) public static final String PARAMETER_IDENTITY_NAME = "name"; // type: String + @Deprecated // alias for PARAMETER_CHAIN + public static final String PARAMETER_CLUSTER = "cluster"; // type: String (one of the CLUSTER_* values) + + public static final String PARAMETER_CHAIN = "chain"; // type: String (one of the CHAIN_* values) + public static final String PARAMETER_AUTH_TOKEN = "auth_token"; // type: String public static final String PARAMETER_PAYLOADS = "payloads"; // type: JSON array of String (base64-encoded payloads) @@ -60,6 +70,8 @@ public class ProtocolContract { public static final String RESULT_ACCOUNTS = "accounts"; // type: JSON array of Account public static final String RESULT_ACCOUNTS_ADDRESS = "address"; // type: String (base64-encoded addresses) public static final String RESULT_ACCOUNTS_LABEL = "label"; // type: String + public static final String RESULT_ACCOUNTS_CHAINS = "chains"; // type: String + // RESULT_ACCOUNTS optionally includes a RESULT_SUPPORTED_FEATURES public static final String RESULT_WALLET_URI_BASE = "wallet_uri_base"; // type: String (absolute URI) @@ -83,5 +95,20 @@ public class ProtocolContract { public static final String CLUSTER_TESTNET = "testnet"; public static final String CLUSTER_DEVNET = "devnet"; + public static final String CHAIN_SOLANA_MAINNET = "solana:mainnet"; + public static final String CHAIN_SOLANA_TESTNET = "solana:testnet"; + public static final String CHAIN_SOLANA_DEVNET = "solana:devnet"; + + public static final String NAMESPACE_SOLANA = "solana"; + + // Mandatory Features + public static final String FEATURE_ID_SIGN_MESSAGES = "solana:signAndSendTransaction"; + public static final String FEATURE_ID_SIGN_TRANSACTIONS = "solana:signTransactions"; + + // Optional Features + public static final String FEATURE_ID_SIGN_AND_SEND_TRANSACTIONS = "solana:signAndSendTransaction"; + public static final String FEATURE_ID_SIGN_IN_WITH_SOLANA = "solana:signInWithSolana"; + public static final String FEATURE_ID_CLONE_AUTHORIZATION = "solana:cloneAuthorization"; + private ProtocolContract() {} } diff --git a/android/common/src/main/java/com/solana/mobilewalletadapter/common/util/Identifier.java b/android/common/src/main/java/com/solana/mobilewalletadapter/common/util/Identifier.java new file mode 100644 index 000000000..dd84e4032 --- /dev/null +++ b/android/common/src/main/java/com/solana/mobilewalletadapter/common/util/Identifier.java @@ -0,0 +1,35 @@ +package com.solana.mobilewalletadapter.common.util; + +import androidx.annotation.NonNull; + +import com.solana.mobilewalletadapter.common.ProtocolContract; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Identifier { + + // matches "{namespace}:{reference}", no whitespace + private static final Pattern namespacedIdentifierPattern = Pattern.compile("^\\S+:\\S+$"); + + public static boolean isValidIdentifier(@NonNull String input) { + Matcher matcher = namespacedIdentifierPattern.matcher(input); + return matcher.find(); + } + + public static @NonNull String clusterToChainIdentifier(@NonNull String cluster) + throws IllegalArgumentException { + switch (cluster) { + case ProtocolContract.CLUSTER_MAINNET_BETA: + return ProtocolContract.CHAIN_SOLANA_MAINNET; + case ProtocolContract.CLUSTER_TESTNET: + return ProtocolContract.CHAIN_SOLANA_TESTNET; + case ProtocolContract.CLUSTER_DEVNET: + return ProtocolContract.CHAIN_SOLANA_DEVNET; + default: + throw new IllegalArgumentException("input is not a valid solana cluster"); + } + } + + private Identifier() {} +} diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/authorization/AuthRecord.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/authorization/AuthRecord.java index c3c473c33..5ac9be0d5 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/authorization/AuthRecord.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/authorization/AuthRecord.java @@ -33,6 +33,9 @@ public class AuthRecord { public final String accountLabel; @NonNull + public final String chain; + + @NonNull @Deprecated public final String cluster; @NonNull @@ -53,7 +56,7 @@ public class AuthRecord { @NonNull IdentityRecord identity, @NonNull byte[] publicKey, @Nullable String accountLabel, - @NonNull String cluster, + @NonNull String chain, @NonNull byte[] scope, @Nullable Uri walletUriBase, @IntRange(from = 1) int publicKeyId, @@ -66,7 +69,8 @@ public class AuthRecord { this.identity = identity; this.publicKey = publicKey; this.accountLabel = accountLabel; - this.cluster = cluster; + this.chain = chain; + this.cluster = chain; this.scope = scope; this.walletUriBase = walletUriBase; this.publicKeyId = publicKeyId; @@ -112,7 +116,7 @@ public String toString() { "id=" + id + ", identity=" + identity + ", publicKey=" + Arrays.toString(publicKey) + - ", cluster='" + cluster + '\'' + + ", chain='" + chain + '\'' + ", scope=" + Arrays.toString(scope) + ", walletUriBase='" + walletUriBase + '\'' + ", issued=" + issued + diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterConfig.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterConfig.java index 420c712aa..d23df2a6c 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterConfig.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterConfig.java @@ -8,9 +8,13 @@ import androidx.annotation.NonNull; import androidx.annotation.Size; +import com.solana.mobilewalletadapter.common.ProtocolContract; +import com.solana.mobilewalletadapter.common.util.Identifier; + public class MobileWalletAdapterConfig { public static final String LEGACY_TRANSACTION_VERSION = "legacy"; + @Deprecated public final boolean supportsSignAndSendTransactions; @IntRange(from = 0) @@ -27,6 +31,10 @@ public class MobileWalletAdapterConfig { @Size(min = 1) public final Object[] supportedTransactionVersions; + @NonNull + public final String[] optionalFeatures; + + @Deprecated public MobileWalletAdapterConfig(boolean supportsSignAndSendTransactions, @IntRange(from = 0) int maxTransactionsPerSigningRequest, @IntRange(from = 0) int maxMessagesPerSigningRequest, @@ -36,6 +44,8 @@ public MobileWalletAdapterConfig(boolean supportsSignAndSendTransactions, this.maxTransactionsPerSigningRequest = maxTransactionsPerSigningRequest; this.maxMessagesPerSigningRequest = maxMessagesPerSigningRequest; this.noConnectionWarningTimeoutMs = noConnectionWarningTimeoutMs; + this.optionalFeatures = supportsSignAndSendTransactions + ? new String[] { ProtocolContract.FEATURE_ID_SIGN_AND_SEND_TRANSACTIONS } : new String[] {}; for (Object o : supportedTransactionVersions) { if (!((o instanceof String) && LEGACY_TRANSACTION_VERSION.equals((String)o)) && @@ -45,4 +55,35 @@ public MobileWalletAdapterConfig(boolean supportsSignAndSendTransactions, } this.supportedTransactionVersions = supportedTransactionVersions; } + + public MobileWalletAdapterConfig(@IntRange(from = 0) int maxTransactionsPerSigningRequest, + @IntRange(from = 0) int maxMessagesPerSigningRequest, + @NonNull @Size(min = 1) Object[] supportedTransactionVersions, + @IntRange(from = 0) long noConnectionWarningTimeoutMs, + @NonNull String[] supportedFeatures) { + this.maxTransactionsPerSigningRequest = maxTransactionsPerSigningRequest; + this.maxMessagesPerSigningRequest = maxMessagesPerSigningRequest; + this.noConnectionWarningTimeoutMs = noConnectionWarningTimeoutMs; + + for (Object o : supportedTransactionVersions) { + if (!((o instanceof String) && LEGACY_TRANSACTION_VERSION.equals((String)o)) && + !((o instanceof Integer) && ((Integer)o >= 0))) { + throw new IllegalArgumentException("supportedTransactionVersions must be either the string \"legacy\" or a non-negative integer"); + } + } + this.supportedTransactionVersions = supportedTransactionVersions; + + boolean supportsSignAndSendTransactions = false; + for (String featureId : supportedFeatures) { + if (!Identifier.isValidIdentifier(featureId)) { + throw new IllegalArgumentException("supportedFeatures must be a valid namespaced feature identifier of the form '{namespace}:{reference}'"); + } + if (featureId.equals(ProtocolContract.FEATURE_ID_SIGN_AND_SEND_TRANSACTIONS)) { + supportsSignAndSendTransactions = true; + break; + } + } + this.supportsSignAndSendTransactions = supportsSignAndSendTransactions; + this.optionalFeatures = supportedFeatures; + } } diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterServer.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterServer.java index 5f7cf35c9..67d5b22aa 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterServer.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/protocol/MobileWalletAdapterServer.java @@ -19,6 +19,7 @@ import com.solana.mobilewalletadapter.common.util.JsonPack; import com.solana.mobilewalletadapter.common.util.NotifyOnCompleteFuture; import com.solana.mobilewalletadapter.common.util.NotifyingCompletableFuture; +import com.solana.mobilewalletadapter.walletlib.scenario.AuthorizedAccount; import org.json.JSONArray; import org.json.JSONException; @@ -41,11 +42,13 @@ public class MobileWalletAdapterServer extends JsonRpc20Server { public interface MethodHandlers { void authorize(@NonNull AuthorizeRequest request); - void reauthorize(@NonNull ReauthorizeRequest request); void deauthorize(@NonNull DeauthorizeRequest request); void signTransactions(@NonNull SignTransactionsRequest request); void signMessages(@NonNull SignMessagesRequest request); void signAndSendTransactions(@NonNull SignAndSendTransactionsRequest request); + + @Deprecated + void reauthorize(@NonNull ReauthorizeRequest request); } public MobileWalletAdapterServer(@NonNull MobileWalletAdapterConfig config, @@ -63,10 +66,8 @@ protected void dispatchRpc(@Nullable Object id, try { switch (method) { case ProtocolContract.METHOD_AUTHORIZE: - handleAuthorize(id, params); - break; case ProtocolContract.METHOD_REAUTHORIZE: - handleReauthorize(id, params); + handleAuthorize(id, params); break; case ProtocolContract.METHOD_DEAUTHORIZE: handleDeauthorize(id, params); @@ -156,17 +157,19 @@ private void onAuthorizationComplete(@NonNull NotifyOnCompleteFuture" + - ", publicKey=" + Arrays.toString(publicKey) + - ", accountLabel='" + accountLabel + '\'' + ", walletUriBase=" + walletUriBase + + ", accounts=" + Arrays.toString(accounts) + '}'; } } @@ -294,24 +316,35 @@ private void handleAuthorize(@Nullable Object id, @Nullable Object params) throw identityName = null; } - final String cluster = o.optString(ProtocolContract.PARAMETER_CLUSTER, ProtocolContract.CLUSTER_MAINNET_BETA); + final String cluster = o.optString(ProtocolContract.PARAMETER_CLUSTER); + final String chainParam = o.optString(ProtocolContract.PARAMETER_CHAIN); + final String chain = !chainParam.isEmpty() ? chainParam : !cluster.isEmpty() ? cluster : null; + + final String authTokenParam = o.optString(ProtocolContract.PARAMETER_AUTH_TOKEN); + final String authToken = authTokenParam.isEmpty() ? null : authTokenParam; - final AuthorizeRequest request = new AuthorizeRequest(id, identityUri, iconUri, identityName, cluster); + final AuthorizeRequest request = + new AuthorizeRequest(id, identityUri, iconUri, identityName, chain, authToken); request.notifyOnComplete((f) -> mHandler.post(() -> onAuthorizationComplete(f))); mMethodHandlers.authorize(request); } public static class AuthorizeRequest extends AuthorizationRequest { - @NonNull + + @Nullable @Deprecated public final String cluster; + @Nullable + public final String chain; private AuthorizeRequest(@Nullable Object id, @Nullable Uri identityUri, @Nullable Uri iconUri, @Nullable String identityName, - @NonNull String cluster) { - super(id, identityUri, iconUri, identityName); - this.cluster = cluster; + @Nullable String chain, + @Nullable String authToken) { + super(id, identityUri, iconUri, identityName, authToken); + this.chain = chain; + this.cluster = chain; } @Override @@ -326,7 +359,7 @@ public boolean complete(@Nullable AuthorizationResult result) { @Override public String toString() { return "AuthorizeRequest{" + - "cluster='" + cluster + '\'' + + "chain='" + chain + '\'' + '/' + super.toString() + '}'; } @@ -336,6 +369,7 @@ public String toString() { // reauthorize // ============================================================================================= + @Deprecated private void handleReauthorize(@Nullable Object id, @Nullable Object params) throws IOException { if (!(params instanceof JSONObject)) { handleRpcError(id, ERROR_INVALID_PARAMS, "params must be either a JSONObject", null); @@ -393,7 +427,7 @@ private ReauthorizeRequest(@Nullable Object id, @Nullable Uri iconUri, @Nullable String identityName, @NonNull String authToken) { - super(id, identityUri, iconUri, identityName); + super(id, identityUri, iconUri, identityName, authToken); this.authToken = authToken; } @@ -489,7 +523,6 @@ private void handleGetCapabilities(@Nullable Object id, @Nullable Object params) final JSONObject result = new JSONObject(); try { result.put(ProtocolContract.RESULT_SUPPORTS_CLONE_AUTHORIZATION, false); - result.put(ProtocolContract.RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS, mConfig.supportsSignAndSendTransactions); if (mConfig.maxTransactionsPerSigningRequest != 0) { result.put(ProtocolContract.RESULT_MAX_TRANSACTIONS_PER_REQUEST, mConfig.maxTransactionsPerSigningRequest); } @@ -497,6 +530,10 @@ private void handleGetCapabilities(@Nullable Object id, @Nullable Object params) result.put(ProtocolContract.RESULT_MAX_MESSAGES_PER_REQUEST, mConfig.maxMessagesPerSigningRequest); } result.put(ProtocolContract.RESULT_SUPPORTED_TRANSACTION_VERSIONS, new JSONArray(mConfig.supportedTransactionVersions)); + result.put(ProtocolContract.RESULT_SUPPORTED_FEATURES, new JSONArray(mConfig.optionalFeatures)); + + // retained for backwards compatibility + result.put(ProtocolContract.RESULT_SUPPORTS_SIGN_AND_SEND_TRANSACTIONS, mConfig.supportsSignAndSendTransactions); } catch (JSONException e) { throw new RuntimeException("Failed preparing get_capabilities response", e); } diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizeRequest.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizeRequest.java index c9e8845dd..f415d5aa4 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizeRequest.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizeRequest.java @@ -8,6 +8,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.Size; import com.solana.mobilewalletadapter.common.util.NotifyingCompletableFuture; import com.solana.mobilewalletadapter.walletlib.protocol.MobileWalletAdapterServer; @@ -26,18 +27,18 @@ public class AuthorizeRequest protected final Uri mIconUri; @NonNull - protected final String mCluster; + protected final String mChain; /*package*/ AuthorizeRequest(@NonNull NotifyingCompletableFuture request, @Nullable String identityName, @Nullable Uri identityUri, @Nullable Uri iconUri, - @NonNull String cluster) { + @NonNull String chain) { super(request); mIdentityName = identityName; mIdentityUri = identityUri; mIconUri = iconUri; - mCluster = cluster; + mChain = chain; } @Nullable @@ -55,16 +56,30 @@ public Uri getIconRelativeUri() { return mIconUri; } - @NonNull + @NonNull @Deprecated public String getCluster() { - return mCluster; + return mChain; } + @NonNull + public String getChain() { + return mChain; + } + + @Deprecated public void completeWithAuthorize(@NonNull byte[] publicKey, @Nullable String accountLabel, @Nullable Uri walletUriBase, @Nullable byte[] scope) { - mRequest.complete(new Result(publicKey, accountLabel, walletUriBase, scope)); + AuthorizedAccount[] accounts = new AuthorizedAccount[] { + new AuthorizedAccount(publicKey, accountLabel, null, null)}; + mRequest.complete(new Result(accounts, walletUriBase, scope)); + } + + public void completeWithAuthorize(@NonNull AuthorizedAccount[] accounts, + @Nullable Uri walletUriBase, + @Nullable byte[] scope) { + mRequest.complete(new Result(accounts, walletUriBase, scope)); } public void completeWithDecline() { @@ -77,21 +92,17 @@ public void completeWithClusterNotSupported() { } /*package*/ static class Result { - @NonNull - /*package*/ final byte[] publicKey; - @Nullable - /*package*/ final String accountLabel; + @NonNull @Size(min = 1) + /*package*/ final AuthorizedAccount[] accounts; @Nullable /*package*/ final Uri walletUriBase; @Nullable /*package*/ final byte[] scope; - private Result(@NonNull byte[] publicKey, - @Nullable String accountLabel, + private Result(@NonNull @Size(min = 1) AuthorizedAccount[] accounts, @Nullable Uri walletUriBase, @Nullable byte[] scope) { - this.publicKey = publicKey; - this.accountLabel = accountLabel; + this.accounts = accounts; this.walletUriBase = walletUriBase; this.scope = scope; } diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizedAccount.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizedAccount.java new file mode 100644 index 000000000..1b5365719 --- /dev/null +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/AuthorizedAccount.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 Solana Mobile Inc. + */ + +package com.solana.mobilewalletadapter.walletlib.scenario; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.Arrays; + +public class AuthorizedAccount { + @NonNull + public final byte[] publicKey; + @Nullable + public final String accountLabel; + @Nullable + public final String[] chains; + @Nullable + public final String[] features; + + public AuthorizedAccount(@NonNull byte[] publicKey, + @Nullable String accountLabel, + @Nullable String[] chains, + @Nullable String[] features) { + this.publicKey = publicKey; + this.accountLabel = accountLabel; + this.chains = chains; + this.features = features; + } + + @NonNull + @Override + public String toString() { + return "AuthorizedAccount{" + + "publicKey=" + Arrays.toString(publicKey) + + ", accountLabel='" + accountLabel + '\'' + + ", chains=" + Arrays.toString(chains) + + ", features=" + Arrays.toString(features) + + '}'; + } +} diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/BaseVerifiableIdentityRequest.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/BaseVerifiableIdentityRequest.java index 21f45556e..9ef37d4b0 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/BaseVerifiableIdentityRequest.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/BaseVerifiableIdentityRequest.java @@ -25,7 +25,7 @@ protected final Uri mIconUri; @NonNull - protected final String mCluster; + protected final String mChain; @NonNull protected final byte[] mAuthorizationScope; @@ -34,14 +34,14 @@ protected BaseVerifiableIdentityRequest(@NonNull T request, @Nullable String identityName, @Nullable Uri identityUri, @Nullable Uri iconUri, - @NonNull String cluster, + @NonNull String chain, @NonNull byte[] authorizationScope) { super(request); mIdentityName = identityName; mIdentityUri = identityUri; mIconUri = iconUri; mAuthorizationScope = authorizationScope; - mCluster = cluster; + mChain = chain; } @Nullable @@ -59,9 +59,14 @@ public Uri getIconRelativeUri() { return mIconUri; } - @NonNull + @NonNull @Deprecated public String getCluster() { - return mCluster; + return mChain; + } + + @NonNull + public String getChain() { + return mChain; } @NonNull diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalScenario.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalScenario.java index b949c4209..90c439743 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalScenario.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalScenario.java @@ -14,6 +14,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.solana.mobilewalletadapter.common.ProtocolContract; import com.solana.mobilewalletadapter.common.protocol.MessageReceiver; import com.solana.mobilewalletadapter.common.protocol.MobileWalletAdapterSessionCommon; import com.solana.mobilewalletadapter.common.util.NotifyingCompletableFuture; @@ -191,6 +192,14 @@ public void authorize(@NonNull MobileWalletAdapterServer.AuthorizeRequest reques mActiveAuthorization = null; } + if (request.authToken != null) { + doReauthorize(request); + return; + } + + final String chain = request.chain != null + ? request.chain : ProtocolContract.CHAIN_SOLANA_MAINNET; + final NotifyingCompletableFuture future = new NotifyingCompletableFuture<>(); future.notifyOnComplete(f -> mIoHandler.post(() -> { // Note: run in IO thread context try { @@ -200,9 +209,10 @@ public void authorize(@NonNull MobileWalletAdapterServer.AuthorizeRequest reques final String name = request.identityName != null ? request.identityName : ""; final Uri uri = request.identityUri != null ? request.identityUri : Uri.EMPTY; final Uri relativeIconUri = request.iconUri != null ? request.iconUri : Uri.EMPTY; + final AuthorizedAccount account = authorize.accounts[0]; // TODO(#44): support multiple addresses final AuthRecord authRecord = mAuthRepository.issue( - name, uri, relativeIconUri, authorize.publicKey, - authorize.accountLabel, request.cluster, authorize.walletUriBase, + name, uri, relativeIconUri, account.publicKey, + account.accountLabel, chain, authorize.walletUriBase, authorize.scope); Log.d(TAG, "Authorize request completed successfully; issued auth: " + authRecord); synchronized (mLock) { @@ -211,7 +221,7 @@ public void authorize(@NonNull MobileWalletAdapterServer.AuthorizeRequest reques final String authToken = mAuthRepository.toAuthToken(authRecord); request.complete(new MobileWalletAdapterServer.AuthorizationResult( - authToken, authorize.publicKey, authorize.accountLabel, + authToken, authorize.accounts, authorize.walletUriBase)); } else { request.completeExceptionally(new MobileWalletAdapterServer.RequestDeclinedException( @@ -229,8 +239,77 @@ public void authorize(@NonNull MobileWalletAdapterServer.AuthorizeRequest reques })); mIoHandler.post(() -> mCallbacks.onAuthorizeRequest(new AuthorizeRequest( + future, request.identityName, request.identityUri, request.iconUri, chain))); + } + + private void doReauthorize(@NonNull MobileWalletAdapterServer.AuthorizeRequest request) { + assert request.authToken != null; + final AuthRecord authRecord = mAuthRepository.fromAuthToken(request.authToken); + if (authRecord == null) { + mIoHandler.post(() -> request.completeExceptionally( + new MobileWalletAdapterServer.AuthorizationNotValidException( + "auth_token not valid for this request"))); + return; + } + + if (request.chain != null && !authRecord.chain.equals(request.chain)) { + mIoHandler.post(() -> request.completeExceptionally( + new MobileWalletAdapterServer.AuthorizationNotValidException( + "requested chain not valid for specified auth_token"))); + return; + } + + final NotifyingCompletableFuture future = new NotifyingCompletableFuture<>(); + future.notifyOnComplete(f -> mIoHandler.post(() -> { // Note: run in IO thread context + try { + final Boolean reauthorize = f.get(); // won't block + if (!reauthorize) { + mIoHandler.post(() -> request.completeExceptionally( + new MobileWalletAdapterServer.RequestDeclinedException( + "app declined reauthorization request"))); + mAuthRepository.revoke(authRecord); + return; + } + + final AuthRecord reissuedAuthRecord = mAuthRepository.reissue(authRecord); + if (reissuedAuthRecord == null) { + // No need to explicitly revoke the old auth token; that is part of the + // reissue method contract + mIoHandler.post(() -> request.completeExceptionally( + new MobileWalletAdapterServer.RequestDeclinedException( + "auth_token not valid for reissue"))); + return; + } + synchronized (mLock) { + mActiveAuthorization = reissuedAuthRecord; + } + + final String authToken; + if (reissuedAuthRecord == authRecord) { + // Reissued same auth record; don't regenerate the token + authToken = request.authToken; + } else { + authToken = mAuthRepository.toAuthToken(reissuedAuthRecord); + } + + mIoHandler.post(() -> request.complete( + new MobileWalletAdapterServer.AuthorizationResult( + authToken, authRecord.publicKey, authRecord.accountLabel, + authRecord.walletUriBase))); + } catch (ExecutionException e) { + final Throwable cause = e.getCause(); + assert(cause instanceof Exception); // expected to always be an Exception + request.completeExceptionally((Exception)cause); + } catch (InterruptedException e) { + throw new RuntimeException("Unexpected interruption while waiting for reauthorization", e); + } catch (CancellationException e) { + request.cancel(true); + } + })); + + mIoHandler.post(() -> mCallbacks.onReauthorizeRequest(new ReauthorizeRequest( future, request.identityName, request.identityUri, request.iconUri, - request.cluster))); + authRecord.chain, authRecord.scope))); } @Override @@ -299,7 +378,7 @@ public void reauthorize(@NonNull MobileWalletAdapterServer.ReauthorizeRequest re mIoHandler.post(() -> mCallbacks.onReauthorizeRequest(new ReauthorizeRequest( future, request.identityName, request.identityUri, request.iconUri, - authRecord.cluster, authRecord.scope))); + authRecord.chain, authRecord.scope))); } @Override diff --git a/android/walletlib/src/test/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalWebSocketServerScenarioTest.java b/android/walletlib/src/test/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalWebSocketServerScenarioTest.java index 608e88bf8..7e2651676 100644 --- a/android/walletlib/src/test/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalWebSocketServerScenarioTest.java +++ b/android/walletlib/src/test/java/com/solana/mobilewalletadapter/walletlib/scenario/LocalWebSocketServerScenarioTest.java @@ -37,11 +37,11 @@ public void testLowPowerNoConnectionCallbackIsNotCalled() throws InterruptedExce AuthIssuerConfig authConfig = new AuthIssuerConfig("Test"); MobileWalletAdapterConfig config = new MobileWalletAdapterConfig( - false, 1, 1, new Object[] { "legacy" }, - noConnectionTimeout + noConnectionTimeout, + new String[] {} ); CountDownLatch latch = new CountDownLatch(1); @@ -76,11 +76,11 @@ public void testLowPowerNoConnectionCallbackIsCalled() throws InterruptedExcepti AuthIssuerConfig authConfig = new AuthIssuerConfig("Test"); MobileWalletAdapterConfig config = new MobileWalletAdapterConfig( - false, 1, 1, new Object[] { "legacy" }, - noConnectionTimeout + noConnectionTimeout, + new String[] {} ); CountDownLatch latch = new CountDownLatch(1);