From cbb4fd0f2283f6fe23c152ae72a1f317848da4c6 Mon Sep 17 00:00:00 2001
From: Jun Luo <4catcode@gmail.com>
Date: Tue, 23 Dec 2025 11:44:08 +0800
Subject: [PATCH 1/4] feat: add SEP-0045 support.
---
.../java/org/stellar/sdk/Sep45Challenge.java | 678 +++++++++
.../InvalidSep45ChallengeException.java | 16 +
.../org/stellar/sdk/Sep45ChallengeTest.java | 1305 +++++++++++++++++
3 files changed, 1999 insertions(+)
create mode 100644 src/main/java/org/stellar/sdk/Sep45Challenge.java
create mode 100644 src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java
create mode 100644 src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
diff --git a/src/main/java/org/stellar/sdk/Sep45Challenge.java b/src/main/java/org/stellar/sdk/Sep45Challenge.java
new file mode 100644
index 000000000..5e32c5b51
--- /dev/null
+++ b/src/main/java/org/stellar/sdk/Sep45Challenge.java
@@ -0,0 +1,678 @@
+package org.stellar.sdk;
+
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import lombok.Builder;
+import lombok.NonNull;
+import lombok.Value;
+import org.jetbrains.annotations.Nullable;
+import org.stellar.sdk.exception.InvalidSep45ChallengeException;
+import org.stellar.sdk.operations.InvokeHostFunctionOperation;
+import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse;
+import org.stellar.sdk.scval.Scv;
+import org.stellar.sdk.xdr.HostFunction;
+import org.stellar.sdk.xdr.HostFunctionType;
+import org.stellar.sdk.xdr.InvokeContractArgs;
+import org.stellar.sdk.xdr.SCVal;
+import org.stellar.sdk.xdr.SCValType;
+import org.stellar.sdk.xdr.SorobanAddressCredentials;
+import org.stellar.sdk.xdr.SorobanAuthorizationEntries;
+import org.stellar.sdk.xdr.SorobanAuthorizationEntry;
+import org.stellar.sdk.xdr.SorobanAuthorizedFunctionType;
+import org.stellar.sdk.xdr.SorobanAuthorizedInvocation;
+import org.stellar.sdk.xdr.SorobanCredentials;
+import org.stellar.sdk.xdr.SorobanCredentialsType;
+
+/**
+ * Stellar Web Authentication Utilities for Contract Accounts (SEP-0045).
+ *
+ *
This class provides utilities for building, reading, and verifying SEP-45 challenge
+ * authorization entries for contract account authentication on the Stellar network.
+ *
+ * @see SEP-0045
+ */
+public class Sep45Challenge {
+ /** The expected function name for SEP-45 web authentication. */
+ public static final String WEB_AUTH_VERIFY_FUNCTION_NAME = "web_auth_verify";
+
+ /**
+ * A null account used for simulation purposes. This is the account
+ * "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF".
+ */
+ public static final String NULL_ACCOUNT =
+ "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+
+ private Sep45Challenge() {
+ // no instance
+ }
+
+ /** Default number of ledgers until authorization expires (~15 minutes at 5 seconds/ledger). */
+ public static final long DEFAULT_EXPIRE_IN_LEDGERS = 180;
+
+ /**
+ * Builds a SEP-45 challenge authorization entries for a client contract account.
+ *
+ *
This method creates challenge authorization entries that can be used to authenticate a
+ * contract account.
+ *
+ * @param server The Soroban RPC server to use for simulating the transaction.
+ * @param serverSigner The server's signing keypair.
+ * @param clientContractId The client's contract account ID (C... address).
+ * @param homeDomain The home domain of the service requiring authentication.
+ * @param webAuthDomain The domain of the web authentication service.
+ * @param webAuthContractId The contract ID for the web authentication contract.
+ * @param network The Stellar network.
+ * @param nonce Optional nonce value. If null, a random 48-byte value will be generated and
+ * base64-encoded.
+ * @param expireInLedgers Number of ledgers from current ledger until authorization expires. If
+ * null, defaults to {@link #DEFAULT_EXPIRE_IN_LEDGERS} (~15 minutes).
+ * @return A SorobanAuthorizationEntries containing the authentication entries.
+ * @throws InvalidSep45ChallengeException If building the challenge fails.
+ */
+ public static SorobanAuthorizationEntries buildChallengeAuthorizationEntries(
+ @NonNull SorobanServer server,
+ @NonNull KeyPair serverSigner,
+ @NonNull String clientContractId,
+ @NonNull String homeDomain,
+ @NonNull String webAuthDomain,
+ @NonNull String webAuthContractId,
+ @NonNull Network network,
+ @Nullable String nonce,
+ @Nullable Long expireInLedgers) {
+ return buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ clientContractId,
+ homeDomain,
+ webAuthDomain,
+ webAuthContractId,
+ network,
+ nonce,
+ expireInLedgers,
+ null,
+ null);
+ }
+
+ /**
+ * Builds a SEP-45 challenge authorization entries for a client contract account with optional
+ * client domain verification.
+ *
+ *
This method creates challenge authorization entries that can be used to authenticate a
+ * contract account, optionally including client domain verification.
+ *
+ * @param server The Soroban RPC server to use for simulating the transaction.
+ * @param serverSigner The server's signing keypair.
+ * @param clientContractId The client's contract account ID (C... address).
+ * @param homeDomain The home domain of the service requiring authentication.
+ * @param webAuthDomain The domain of the web authentication service.
+ * @param webAuthContractId The contract ID for the web authentication contract.
+ * @param network The Stellar network.
+ * @param nonce Optional nonce value. If null, a random 48-byte value will be generated and
+ * base64-encoded.
+ * @param expireInLedgers Number of ledgers from current ledger until authorization expires. If
+ * null, defaults to {@link #DEFAULT_EXPIRE_IN_LEDGERS} (~15 minutes).
+ * @param clientDomain Optional client domain for client domain verification.
+ * @param clientDomainAccountId Optional client domain account ID (G... address) for verification.
+ * @return A SorobanAuthorizationEntries containing the authentication entries.
+ * @throws InvalidSep45ChallengeException If building the challenge fails.
+ */
+ public static SorobanAuthorizationEntries buildChallengeAuthorizationEntries(
+ @NonNull SorobanServer server,
+ @NonNull KeyPair serverSigner,
+ @NonNull String clientContractId,
+ @NonNull String homeDomain,
+ @NonNull String webAuthDomain,
+ @NonNull String webAuthContractId,
+ @NonNull Network network,
+ @Nullable String nonce,
+ @Nullable Long expireInLedgers,
+ @Nullable String clientDomain,
+ @Nullable String clientDomainAccountId) {
+
+ // Use default expiration if not specified
+ if (expireInLedgers == null) {
+ expireInLedgers = DEFAULT_EXPIRE_IN_LEDGERS;
+ }
+
+ // Validate client contract ID
+ if (!StrKey.isValidContract(clientContractId)) {
+ throw new InvalidSep45ChallengeException(
+ "clientContractId: " + clientContractId + " is not a valid contract id");
+ }
+
+ // Validate web auth contract ID
+ if (!StrKey.isValidContract(webAuthContractId)) {
+ throw new InvalidSep45ChallengeException(
+ "webAuthContractId: " + webAuthContractId + " is not a valid contract id");
+ }
+
+ // Validate client domain and client domain account ID consistency
+ if ((clientDomain == null) != (clientDomainAccountId == null)) {
+ throw new InvalidSep45ChallengeException(
+ "clientDomain and clientDomainAccountId must both be provided or both be null");
+ }
+
+ // Generate nonce if not provided (48 random bytes, base64-encoded)
+ if (nonce == null) {
+ byte[] nonceBytes = new byte[48];
+ new SecureRandom().nextBytes(nonceBytes);
+ nonce = Base64Factory.getInstance().encodeToString(nonceBytes);
+ }
+
+ // Build the function arguments as a Map
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(clientContractId));
+ if (clientDomain != null) {
+ argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(clientDomain));
+ argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(clientDomainAccountId));
+ }
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(homeDomain));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(nonce));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(webAuthDomain));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(serverSigner.getAccountId()));
+
+ // Build the invocation with a single Map argument
+ InvokeHostFunctionOperation operation =
+ InvokeHostFunctionOperation.invokeContractFunctionOperationBuilder(
+ webAuthContractId,
+ WEB_AUTH_VERIFY_FUNCTION_NAME,
+ Collections.singletonList(Scv.toMap(argsMap)))
+ .build();
+
+ // Create a transaction for simulation
+ Account nullAccount = new Account(NULL_ACCOUNT, 0L);
+ Transaction transaction =
+ new TransactionBuilder(nullAccount, network)
+ .setBaseFee(100)
+ .addOperation(operation)
+ .addPreconditions(
+ TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build())
+ .build();
+
+ // Simulate the transaction
+ SimulateTransactionResponse simulateResponse = server.simulateTransaction(transaction);
+
+ if (simulateResponse.getError() != null) {
+ throw new InvalidSep45ChallengeException(
+ "Transaction simulation failed: " + simulateResponse.getError());
+ }
+
+ if (simulateResponse.getResults() == null || simulateResponse.getResults().isEmpty()) {
+ throw new InvalidSep45ChallengeException("Transaction simulation did not return any results");
+ }
+
+ SimulateTransactionResponse.SimulateHostFunctionResult result =
+ simulateResponse.getResults().get(0);
+
+ if (result.getAuth() == null || result.getAuth().isEmpty()) {
+ throw new InvalidSep45ChallengeException(
+ "Transaction simulation did not return any authorization entries");
+ }
+
+ // Calculate the absolute signature expiration ledger
+ long signatureExpirationLedger = simulateResponse.getLatestLedger() + expireInLedgers;
+
+ // Parse and sign the authorization entries
+ List signedEntries = new ArrayList<>();
+ for (String authXdr : result.getAuth()) {
+ SorobanAuthorizationEntry entry;
+ try {
+ entry = SorobanAuthorizationEntry.fromXdrBase64(authXdr);
+ } catch (IOException e) {
+ throw new InvalidSep45ChallengeException(
+ "Failed to parse authorization entry: " + authXdr, e);
+ }
+
+ // Check if this entry needs to be signed by the server
+ if (entry.getCredentials().getDiscriminant()
+ == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) {
+ Address address = Address.fromSCAddress(entry.getCredentials().getAddress().getAddress());
+ if (address.getAddressType() == Address.AddressType.ACCOUNT
+ && address.getEncodedAddress().equals(serverSigner.getAccountId())) {
+ // Sign this entry with the server signer
+ entry = Auth.authorizeEntry(entry, serverSigner, signatureExpirationLedger, network);
+ }
+ }
+
+ signedEntries.add(entry);
+ }
+
+ return new SorobanAuthorizationEntries(signedEntries.toArray(new SorobanAuthorizationEntry[0]));
+ }
+
+ /**
+ * Reads and validates a SEP-45 challenge authorization entries without verifying signatures.
+ *
+ * This method decodes the authorization entries, validates their structure, and extracts the
+ * challenge data. It does not verify the signatures; use {@link
+ * #verifyChallengeAuthorizationEntries} for full verification.
+ *
+ * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries.
+ * @param serverAccountId The expected server account ID (G... address).
+ * @param webAuthContractId The expected web authentication contract ID (C... address).
+ * @param homeDomains A list of acceptable home domains.
+ * @param webAuthDomain The expected web auth domain.
+ * @return A {@link ChallengeAuthorizationEntries} object containing the parsed challenge data.
+ * @throws InvalidSep45ChallengeException If the challenge is invalid.
+ */
+ public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries(
+ @NonNull String authorizationEntriesXdr,
+ @NonNull String serverAccountId,
+ @NonNull String webAuthContractId,
+ @NonNull String[] homeDomains,
+ @NonNull String webAuthDomain) {
+
+ if (homeDomains == null || homeDomains.length == 0) {
+ throw new IllegalArgumentException(
+ "At least one domain name must be included in homeDomains.");
+ }
+
+ // Validate server account ID
+ if (StrKey.decodeVersionByte(serverAccountId) != StrKey.VersionByte.ACCOUNT_ID) {
+ throw new InvalidSep45ChallengeException(
+ "serverAccountId: " + serverAccountId + " is not a valid account id");
+ }
+
+ // Validate web auth contract ID
+ if (!StrKey.isValidContract(webAuthContractId)) {
+ throw new InvalidSep45ChallengeException(
+ "webAuthContractId: " + webAuthContractId + " is not a valid contract id");
+ }
+
+ // Decode the authorization entries
+ List entries;
+ try {
+ org.stellar.sdk.xdr.SorobanAuthorizationEntries xdrEntries =
+ org.stellar.sdk.xdr.SorobanAuthorizationEntries.fromXdrBase64(authorizationEntriesXdr);
+ entries = Arrays.asList(xdrEntries.getSorobanAuthorizationEntries());
+ } catch (IOException e) {
+ throw new InvalidSep45ChallengeException("Failed to parse authorization entries XDR", e);
+ }
+
+ // Validate we have at least 2 entries (server + client)
+ if (entries.size() < 2) {
+ throw new InvalidSep45ChallengeException(
+ "Authorization entries must contain at least 2 entries (server and client)");
+ }
+
+ // Get the reference invocation from the first entry
+ SorobanAuthorizedInvocation referenceInvocation = entries.get(0).getRootInvocation();
+
+ // Validate and extract the contract call information
+ if (referenceInvocation.getFunction().getDiscriminant()
+ != SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN) {
+ throw new InvalidSep45ChallengeException(
+ "Authorization entry function type must be SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN");
+ }
+
+ // Validate no sub-invocations
+ if (referenceInvocation.getSubInvocations() != null
+ && referenceInvocation.getSubInvocations().length > 0) {
+ throw new InvalidSep45ChallengeException("Authorization entry must not have sub-invocations");
+ }
+
+ InvokeContractArgs contractArgs = referenceInvocation.getFunction().getContractFn();
+
+ // Validate contract address
+ Address contractAddress = Address.fromSCAddress(contractArgs.getContractAddress());
+ if (!contractAddress.getEncodedAddress().equals(webAuthContractId)) {
+ throw new InvalidSep45ChallengeException(
+ String.format(
+ "Contract address mismatch: expected %s, got %s",
+ webAuthContractId, contractAddress.getEncodedAddress()));
+ }
+
+ // Validate function name
+ String functionName = contractArgs.getFunctionName().getSCSymbol().toString();
+ if (!WEB_AUTH_VERIFY_FUNCTION_NAME.equals(functionName)) {
+ throw new InvalidSep45ChallengeException(
+ String.format(
+ "Function name mismatch: expected %s, got %s",
+ WEB_AUTH_VERIFY_FUNCTION_NAME, functionName));
+ }
+
+ // Parse the function arguments - expect exactly one Map argument
+ SCVal[] args = contractArgs.getArgs();
+ if (args.length != 1) {
+ throw new InvalidSep45ChallengeException(
+ "Expected exactly one argument in contract function call");
+ }
+
+ SCVal argsVal = args[0];
+ if (argsVal.getDiscriminant() != SCValType.SCV_MAP) {
+ throw new InvalidSep45ChallengeException("Expected Map argument in contract function call");
+ }
+
+ LinkedHashMap argsMap = Scv.fromMap(argsVal);
+
+ String account = null;
+ String homeDomain = null;
+ String nonce = null;
+ String webAuthDomainValue = null;
+ String webAuthDomainAccount = null;
+ String clientDomain = null;
+ String clientDomainAccount = null;
+
+ for (java.util.Map.Entry entry : argsMap.entrySet()) {
+ SCVal keyVal = entry.getKey();
+ SCVal valueVal = entry.getValue();
+
+ if (keyVal.getDiscriminant() != SCValType.SCV_SYMBOL) {
+ throw new InvalidSep45ChallengeException("Argument key must be a symbol");
+ }
+
+ String key = Scv.fromSymbol(keyVal);
+
+ switch (key) {
+ case "account":
+ account = new String(Scv.fromString(valueVal));
+ break;
+ case "home_domain":
+ homeDomain = new String(Scv.fromString(valueVal));
+ break;
+ case "nonce":
+ nonce = new String(Scv.fromString(valueVal));
+ break;
+ case "web_auth_domain":
+ webAuthDomainValue = new String(Scv.fromString(valueVal));
+ break;
+ case "web_auth_domain_account":
+ webAuthDomainAccount = new String(Scv.fromString(valueVal));
+ break;
+ case "client_domain":
+ clientDomain = new String(Scv.fromString(valueVal));
+ break;
+ case "client_domain_account":
+ clientDomainAccount = new String(Scv.fromString(valueVal));
+ break;
+ default:
+ // Unknown argument, ignore
+ break;
+ }
+ }
+
+ // Validate required fields
+ if (account == null) {
+ throw new InvalidSep45ChallengeException("Missing required argument: account");
+ }
+ if (homeDomain == null) {
+ throw new InvalidSep45ChallengeException("Missing required argument: home_domain");
+ }
+ if (nonce == null) {
+ throw new InvalidSep45ChallengeException("Missing required argument: nonce");
+ }
+ if (webAuthDomainValue == null) {
+ throw new InvalidSep45ChallengeException("Missing required argument: web_auth_domain");
+ }
+ if (webAuthDomainAccount == null) {
+ throw new InvalidSep45ChallengeException(
+ "Missing required argument: web_auth_domain_account");
+ }
+
+ // Validate client contract ID
+ if (!StrKey.isValidContract(account)) {
+ throw new InvalidSep45ChallengeException(
+ "account: " + account + " is not a valid contract id");
+ }
+
+ // Validate home domain
+ boolean homeDomainMatched = false;
+ for (String domain : homeDomains) {
+ if (domain.equals(homeDomain)) {
+ homeDomainMatched = true;
+ break;
+ }
+ }
+ if (!homeDomainMatched) {
+ throw new InvalidSep45ChallengeException(
+ "home_domain: " + homeDomain + " is not in the list of allowed home domains");
+ }
+
+ // Validate web auth domain
+ if (!webAuthDomain.equals(webAuthDomainValue)) {
+ throw new InvalidSep45ChallengeException(
+ String.format(
+ "web_auth_domain mismatch: expected %s, got %s", webAuthDomain, webAuthDomainValue));
+ }
+
+ // Validate web auth domain account
+ if (!serverAccountId.equals(webAuthDomainAccount)) {
+ throw new InvalidSep45ChallengeException(
+ String.format(
+ "web_auth_domain_account mismatch: expected %s, got %s",
+ serverAccountId, webAuthDomainAccount));
+ }
+
+ // Validate client domain and client domain account consistency
+ if ((clientDomain == null) != (clientDomainAccount == null)) {
+ throw new InvalidSep45ChallengeException(
+ "client_domain and client_domain_account must both be present or both be absent");
+ }
+
+ // Validate all entries have the same root invocation
+ for (int i = 1; i < entries.size(); i++) {
+ SorobanAuthorizedInvocation currentInvocation = entries.get(i).getRootInvocation();
+ if (!referenceInvocation.equals(currentInvocation)) {
+ throw new InvalidSep45ChallengeException(
+ "All authorization entries must have the same root invocation");
+ }
+ }
+
+ // Validate credentials types
+ boolean foundServerCredential = false;
+ boolean foundClientCredential = false;
+ boolean foundClientDomainCredential = false;
+
+ for (SorobanAuthorizationEntry entry : entries) {
+ SorobanCredentials credentials = entry.getCredentials();
+ if (credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) {
+ throw new InvalidSep45ChallengeException(
+ "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type");
+ }
+
+ SorobanAddressCredentials addressCredentials = credentials.getAddress();
+ Address credentialAddress = Address.fromSCAddress(addressCredentials.getAddress());
+ String encodedAddress = credentialAddress.getEncodedAddress();
+
+ if (encodedAddress.equals(serverAccountId)) {
+ foundServerCredential = true;
+ } else if (encodedAddress.equals(account)) {
+ foundClientCredential = true;
+ } else if (encodedAddress.equals(clientDomainAccount)) {
+ foundClientDomainCredential = true;
+ }
+ }
+
+ if (!foundServerCredential) {
+ throw new InvalidSep45ChallengeException(
+ "Authorization entries must include a credential for the server account");
+ }
+
+ if (!foundClientCredential) {
+ throw new InvalidSep45ChallengeException(
+ "Authorization entries must include a credential for the client account");
+ }
+
+ if (clientDomainAccount != null && !foundClientDomainCredential) {
+ throw new InvalidSep45ChallengeException(
+ "Authorization entries must include a credential for client_domain_account: "
+ + clientDomainAccount);
+ }
+
+ return ChallengeAuthorizationEntries.builder()
+ .authorizationEntries(entries)
+ .clientContractId(account)
+ .serverAccountId(serverAccountId)
+ .webAuthContractId(webAuthContractId)
+ .homeDomain(homeDomain)
+ .webAuthDomain(webAuthDomainValue)
+ .nonce(nonce)
+ .clientDomain(clientDomain)
+ .clientDomainAccountId(clientDomainAccount)
+ .build();
+ }
+
+ /**
+ * Reads and validates a SEP-45 challenge authorization entries without verifying signatures.
+ *
+ * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries.
+ * @param serverAccountId The expected server account ID (G... address).
+ * @param webAuthContractId The expected web authentication contract ID (C... address).
+ * @param homeDomain The expected home domain.
+ * @param webAuthDomain The expected web auth domain.
+ * @return A {@link ChallengeAuthorizationEntries} object containing the parsed challenge data.
+ * @throws InvalidSep45ChallengeException If the challenge is invalid.
+ */
+ public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries(
+ @NonNull String authorizationEntriesXdr,
+ @NonNull String serverAccountId,
+ @NonNull String webAuthContractId,
+ @NonNull String homeDomain,
+ @NonNull String webAuthDomain) {
+ return readChallengeAuthorizationEntries(
+ authorizationEntriesXdr,
+ serverAccountId,
+ webAuthContractId,
+ new String[] {homeDomain},
+ webAuthDomain);
+ }
+
+ /**
+ * Verifies a SEP-45 challenge authorization entries by simulating the transaction.
+ *
+ * Since contract accounts cannot be queried for signers like traditional Stellar accounts, we
+ * verify signatures by simulating the transaction. A successful simulation indicates valid
+ * signatures.
+ *
+ * @param server The Soroban RPC server to use for simulating the transaction.
+ * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries.
+ * @param serverAccountId The expected server account ID (G... address).
+ * @param webAuthContractId The expected web authentication contract ID (C... address).
+ * @param homeDomains A list of acceptable home domains.
+ * @param webAuthDomain The expected web auth domain.
+ * @param network The Stellar network.
+ * @return A {@link ChallengeAuthorizationEntries} object containing the verified challenge data.
+ * @throws InvalidSep45ChallengeException If the challenge is invalid or verification fails.
+ */
+ public static ChallengeAuthorizationEntries verifyChallengeAuthorizationEntries(
+ @NonNull SorobanServer server,
+ @NonNull String authorizationEntriesXdr,
+ @NonNull String serverAccountId,
+ @NonNull String webAuthContractId,
+ @NonNull String[] homeDomains,
+ @NonNull String webAuthDomain,
+ @NonNull Network network) {
+
+ // First, read and validate the challenge structure
+ ChallengeAuthorizationEntries challenge =
+ readChallengeAuthorizationEntries(
+ authorizationEntriesXdr,
+ serverAccountId,
+ webAuthContractId,
+ homeDomains,
+ webAuthDomain);
+
+ // Build a transaction with the authorization entries
+ List entries = challenge.getAuthorizationEntries();
+ SorobanAuthorizedInvocation invocation = entries.get(0).getRootInvocation();
+ InvokeContractArgs contractArgs = invocation.getFunction().getContractFn();
+
+ InvokeHostFunctionOperation operation =
+ InvokeHostFunctionOperation.builder()
+ .hostFunction(
+ HostFunction.builder()
+ .discriminant(HostFunctionType.HOST_FUNCTION_TYPE_INVOKE_CONTRACT)
+ .invokeContract(contractArgs)
+ .build())
+ .auth(entries)
+ .build();
+
+ Account nullAccount = new Account(NULL_ACCOUNT, 0L);
+ Transaction transaction =
+ new TransactionBuilder(nullAccount, network)
+ .setBaseFee(100)
+ .addOperation(operation)
+ .addPreconditions(
+ TransactionPreconditions.builder().timeBounds(new TimeBounds(0, 0)).build())
+ .build();
+
+ // Simulate the transaction to verify signatures
+ SimulateTransactionResponse simulateResponse = server.simulateTransaction(transaction);
+
+ if (simulateResponse.getError() != null) {
+ throw new InvalidSep45ChallengeException(
+ "Signature verification failed: " + simulateResponse.getError());
+ }
+
+ return challenge;
+ }
+
+ /**
+ * Verifies a SEP-45 challenge authorization entries by simulating the transaction.
+ *
+ * @param server The Soroban RPC server to use for simulating the transaction.
+ * @param authorizationEntriesXdr The base64 XDR-encoded authorization entries.
+ * @param serverAccountId The expected server account ID (G... address).
+ * @param webAuthContractId The expected web authentication contract ID (C... address).
+ * @param homeDomain The expected home domain.
+ * @param webAuthDomain The expected web auth domain.
+ * @param network The Stellar network.
+ * @return A {@link ChallengeAuthorizationEntries} object containing the verified challenge data.
+ * @throws InvalidSep45ChallengeException If the challenge is invalid or verification fails.
+ */
+ public static ChallengeAuthorizationEntries verifyChallengeAuthorizationEntries(
+ @NonNull SorobanServer server,
+ @NonNull String authorizationEntriesXdr,
+ @NonNull String serverAccountId,
+ @NonNull String webAuthContractId,
+ @NonNull String homeDomain,
+ @NonNull String webAuthDomain,
+ @NonNull Network network) {
+ return verifyChallengeAuthorizationEntries(
+ server,
+ authorizationEntriesXdr,
+ serverAccountId,
+ webAuthContractId,
+ new String[] {homeDomain},
+ webAuthDomain,
+ network);
+ }
+
+ /** Contains the parsed data from a SEP-45 challenge. */
+ @Value
+ @Builder
+ public static class ChallengeAuthorizationEntries {
+ /** The list of authorization entries. */
+ @NonNull List authorizationEntries;
+
+ /** The client contract account ID (C... address). */
+ @NonNull String clientContractId;
+
+ /** The server account ID (G... address). */
+ @NonNull String serverAccountId;
+
+ /** The web authentication contract ID (C... address). */
+ @NonNull String webAuthContractId;
+
+ /** The home domain. */
+ @NonNull String homeDomain;
+
+ /** The web auth domain. */
+ @NonNull String webAuthDomain;
+
+ /** The nonce value. */
+ @NonNull String nonce;
+
+ /** The client domain (optional, for client domain verification). */
+ @Nullable String clientDomain;
+
+ /** The client domain account ID (optional, for client domain verification). */
+ @Nullable String clientDomainAccountId;
+ }
+}
diff --git a/src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java b/src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java
new file mode 100644
index 000000000..7a7e2f6d3
--- /dev/null
+++ b/src/main/java/org/stellar/sdk/exception/InvalidSep45ChallengeException.java
@@ -0,0 +1,16 @@
+package org.stellar.sdk.exception;
+
+/** Thrown when building, reading, or verifying a SEP-0045 challenge fails. */
+public class InvalidSep45ChallengeException extends SdkException {
+ public InvalidSep45ChallengeException() {
+ super();
+ }
+
+ public InvalidSep45ChallengeException(String message) {
+ super(message);
+ }
+
+ public InvalidSep45ChallengeException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
new file mode 100644
index 000000000..68b57d40b
--- /dev/null
+++ b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
@@ -0,0 +1,1305 @@
+package org.stellar.sdk;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import lombok.NonNull;
+import okhttp3.HttpUrl;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.Test;
+import org.stellar.sdk.exception.InvalidSep45ChallengeException;
+import org.stellar.sdk.scval.Scv;
+import org.stellar.sdk.xdr.Int64;
+import org.stellar.sdk.xdr.InvokeContractArgs;
+import org.stellar.sdk.xdr.SCSymbol;
+import org.stellar.sdk.xdr.SCVal;
+import org.stellar.sdk.xdr.SorobanAddressCredentials;
+import org.stellar.sdk.xdr.SorobanAuthorizationEntries;
+import org.stellar.sdk.xdr.SorobanAuthorizationEntry;
+import org.stellar.sdk.xdr.SorobanAuthorizedFunction;
+import org.stellar.sdk.xdr.SorobanAuthorizedFunctionType;
+import org.stellar.sdk.xdr.SorobanAuthorizedInvocation;
+import org.stellar.sdk.xdr.SorobanCredentials;
+import org.stellar.sdk.xdr.SorobanCredentialsType;
+import org.stellar.sdk.xdr.Uint32;
+import org.stellar.sdk.xdr.XdrString;
+import org.stellar.sdk.xdr.XdrUnsignedInteger;
+
+public class Sep45ChallengeTest {
+
+ private static final String WEB_AUTH_CONTRACT =
+ "CCSZMK2C2B7UVP3Q4JRWLS3JY7ZB4YTTRSWQXGWQVS24W3PPZXOUHH4R";
+ private static final String SERVER_ACCOUNT =
+ "GCSNRCUM6EDEKSSBQNIOP66ONIM26FVCYP3GHYGD4NR3DK4F635Z32WQ";
+ private static final String CLIENT_CONTRACT =
+ "CBUCJMHBZHQ3EXQ2LMSFVZUWCPH7BCTCYGOQ6LIIO2OUVKU3XDDOO2HN";
+ private static final String CLIENT_DOMAIN_ACCOUNT =
+ "GAFQLAZMGGZT4KSDIGBEIC5IXOVH6ITIFKUQ5ZTDWP3MQWLGVJWTH3TX";
+ private static final String HOME_DOMAIN = "example.com";
+ private static final String WEB_AUTH_DOMAIN = "auth.example.com";
+ private static final String CLIENT_DOMAIN = "client.example.com";
+ private static final String NONCE =
+ "dGVzdG5vbmNlMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU=";
+
+ /**
+ * Builds a valid SorobanAuthorizedInvocation for the web_auth_verify function.
+ *
+ * @param includeClientDomain whether to include client domain fields
+ * @return the authorized invocation
+ */
+ private SorobanAuthorizedInvocation buildValidInvocation(boolean includeClientDomain) {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ if (includeClientDomain) {
+ argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN));
+ argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT));
+ }
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ return SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+ }
+
+ /**
+ * Builds a SorobanAuthorizationEntry with address credentials.
+ *
+ * @param address the address for the credential
+ * @param invocation the root invocation
+ * @return the authorization entry
+ */
+ private SorobanAuthorizationEntry buildAuthorizationEntry(
+ String address, SorobanAuthorizedInvocation invocation) {
+ Address addr = new Address(address);
+
+ SorobanAddressCredentials addressCredentials =
+ SorobanAddressCredentials.builder()
+ .address(addr.toSCAddress())
+ .nonce(new Int64(0L))
+ .signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(0L)))
+ .signature(Scv.toVoid())
+ .build();
+
+ SorobanCredentials credentials =
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)
+ .address(addressCredentials)
+ .build();
+
+ return SorobanAuthorizationEntry.builder()
+ .credentials(credentials)
+ .rootInvocation(invocation)
+ .build();
+ }
+
+ /**
+ * Builds valid authorization entries for testing.
+ *
+ * @param includeClientDomain whether to include client domain
+ * @return list of authorization entries
+ */
+ private List buildValidEntries(boolean includeClientDomain) {
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(includeClientDomain);
+
+ List entries = new ArrayList<>();
+ // Client entry
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ // Server entry
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+
+ if (includeClientDomain) {
+ // Client domain entry
+ entries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation));
+ }
+
+ return entries;
+ }
+
+ @Test
+ public void testReadChallengeSuccessWithClientDomain() {
+ List entries = buildValidEntries(true);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ Sep45Challenge.ChallengeAuthorizationEntries result =
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+
+ assertEquals(CLIENT_CONTRACT, result.getClientContractId());
+ assertEquals(SERVER_ACCOUNT, result.getServerAccountId());
+ assertEquals(WEB_AUTH_CONTRACT, result.getWebAuthContractId());
+ assertEquals(HOME_DOMAIN, result.getHomeDomain());
+ assertEquals(WEB_AUTH_DOMAIN, result.getWebAuthDomain());
+ assertEquals(NONCE, result.getNonce());
+ assertEquals(CLIENT_DOMAIN, result.getClientDomain());
+ assertEquals(CLIENT_DOMAIN_ACCOUNT, result.getClientDomainAccountId());
+ assertEquals(3, result.getAuthorizationEntries().size());
+ }
+
+ @Test
+ public void testReadChallengeSuccessWithoutClientDomain() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ Sep45Challenge.ChallengeAuthorizationEntries result =
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+
+ assertEquals(CLIENT_CONTRACT, result.getClientContractId());
+ assertEquals(SERVER_ACCOUNT, result.getServerAccountId());
+ assertEquals(WEB_AUTH_CONTRACT, result.getWebAuthContractId());
+ assertEquals(HOME_DOMAIN, result.getHomeDomain());
+ assertEquals(WEB_AUTH_DOMAIN, result.getWebAuthDomain());
+ assertEquals(NONCE, result.getNonce());
+ assertNull(result.getClientDomain());
+ assertNull(result.getClientDomainAccountId());
+ assertEquals(2, result.getAuthorizationEntries().size());
+ }
+
+ @Test
+ public void testReadChallengeSuccessWithMultipleHomeDomains() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String[] homeDomains = new String[] {"other.com", HOME_DOMAIN, "another.com"};
+
+ Sep45Challenge.ChallengeAuthorizationEntries result =
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, homeDomains, WEB_AUTH_DOMAIN);
+
+ assertEquals(HOME_DOMAIN, result.getHomeDomain());
+ assertEquals(CLIENT_CONTRACT, result.getClientContractId());
+ }
+
+ @Test(expected = InvalidSep45ChallengeException.class)
+ public void testReadChallengeInvalidXdrFormat() {
+ // Use empty XDR which should fail to parse
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ "AAAAAAAAAA==", SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ }
+
+ @Test
+ public void testReadChallengeLessThanTwoEntries() {
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(false);
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "Authorization entries must contain at least 2 entries (server and client)"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeInconsistentRootInvocation() {
+ SorobanAuthorizedInvocation invocation1 = buildValidInvocation(false);
+
+ // Create a different invocation with different nonce
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString("different_nonce"));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ InvokeContractArgs contractArgs2 =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function2 =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs2)
+ .build();
+
+ SorobanAuthorizedInvocation invocation2 =
+ SorobanAuthorizedInvocation.builder()
+ .function(function2)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation1));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation2));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage().contains("All authorization entries must have the same root invocation"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeHasSubInvocations() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ // Create a sub-invocation
+ SorobanAuthorizedInvocation subInvocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ // Create invocation with sub-invocations
+ SorobanAuthorizedInvocation invocationWithSubs =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[] {subInvocation})
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocationWithSubs));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocationWithSubs));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Authorization entry must not have sub-invocations"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeWrongContractAddress() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String wrongContract = "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC";
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, wrongContract, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Contract address mismatch"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeWrongFunctionName() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString("wrong_function")))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ SorobanAuthorizedInvocation invocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Function name mismatch"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeWrongArgsCount() {
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toSymbol("arg1"), Scv.toSymbol("arg2")})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ SorobanAuthorizedInvocation invocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Expected exactly one argument"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeInvalidStruct() {
+ // Use a symbol instead of a map as argument
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toSymbol("not_a_map")})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ SorobanAuthorizedInvocation invocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Expected Map argument"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingAccount() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ // Missing "account" field
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Missing required argument: account"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingHomeDomain() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ // Missing "home_domain" field
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Missing required argument: home_domain"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingNonce() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ // Missing "nonce" field
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Missing required argument: nonce"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingWebAuthDomain() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ // Missing "web_auth_domain" field
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Missing required argument: web_auth_domain"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingWebAuthDomainAccount() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ // Missing "web_auth_domain_account" field
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Missing required argument: web_auth_domain_account"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeServerAccountMismatch() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String wrongServer = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, wrongServer, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("web_auth_domain_account mismatch"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeWebAuthDomainMismatch() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, "wrong.domain.com");
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("web_auth_domain mismatch"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeHomeDomainMismatchString() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, "wrong.domain.com", WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("is not in the list of allowed home domains"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeHomeDomainMismatchList() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String[] homeDomains = new String[] {"other.com", "another.com"};
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, homeDomains, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("is not in the list of allowed home domains"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeClientDomainWithoutAccount() {
+ // Create entries where client_domain is present but client_domain_account is missing
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN));
+ // Missing client_domain_account
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "client_domain and client_domain_account must both be present or both be absent"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeClientDomainAccountWithoutDomain() {
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ // Missing client_domain
+ argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ String xdr = buildEntriesWithCustomArgs(argsMap);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "client_domain and client_domain_account must both be present or both be absent"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingServerEntry() {
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(false);
+
+ List entries = new ArrayList<>();
+ // Only client entry, missing server entry
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ // Add another random entry that's not the server (use a valid random account)
+ String randomAccount = KeyPair.random().getAccountId();
+ entries.add(buildAuthorizationEntry(randomAccount, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains("Authorization entries must include a credential for the server account"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingClientEntry() {
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(false);
+
+ List entries = new ArrayList<>();
+ // Only server entry, missing client entry
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+ // Add another random entry that's not the client (use a valid random account)
+ String randomAccount = KeyPair.random().getAccountId();
+ entries.add(buildAuthorizationEntry(randomAccount, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains("Authorization entries must include a credential for the client account"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeMissingClientDomainEntry() {
+ // Create entries with client_domain in args but missing the client_domain_account entry
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN));
+ argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT));
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(SERVER_ACCOUNT));
+
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ SorobanAuthorizedInvocation invocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+ // Missing client domain account entry!
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "Authorization entries must include a credential for client_domain_account"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeUnsupportedCredentialsType() {
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(false);
+
+ // Create entry with SOURCE_ACCOUNT credentials type
+ SorobanCredentials sourceCredentials =
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT)
+ .build();
+
+ SorobanAuthorizationEntry sourceEntry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(sourceCredentials)
+ .rootInvocation(invocation)
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(sourceEntry);
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains("All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeInvalidServerAccountId() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ // Use a contract address instead of account address for server account
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, CLIENT_CONTRACT, WEB_AUTH_CONTRACT, HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("is not a valid account id"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeInvalidWebAuthContractId() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, "invalid_contract", HOME_DOMAIN, WEB_AUTH_DOMAIN);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("is not a valid contract id"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeEmptyHomeDomains() {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ try {
+ Sep45Challenge.readChallengeAuthorizationEntries(
+ xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, new String[] {}, WEB_AUTH_DOMAIN);
+ fail("Expected IllegalArgumentException");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("At least one domain name must be included"));
+ }
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesWithClientDomain() throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+ String serverAccountId = serverSigner.getAccountId();
+
+ // Mock the simulate transaction response
+ String mockResponse = buildMockSimulateResponse(serverAccountId, true);
+
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse));
+ mockWebServer.start();
+
+ HttpUrl baseUrl = mockWebServer.url("");
+ SorobanServer server = new SorobanServer(baseUrl.toString());
+
+ SorobanAuthorizationEntries result =
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ CLIENT_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ WEB_AUTH_CONTRACT,
+ Network.TESTNET,
+ NONCE,
+ 180L,
+ CLIENT_DOMAIN,
+ CLIENT_DOMAIN_ACCOUNT);
+
+ assertNotNull(result);
+ assertTrue(result.getSorobanAuthorizationEntries().length >= 2);
+
+ mockWebServer.close();
+ server.close();
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesWithoutClientDomain() throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+ String serverAccountId = serverSigner.getAccountId();
+
+ // Mock the simulate transaction response
+ String mockResponse = buildMockSimulateResponse(serverAccountId, false);
+
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse));
+ mockWebServer.start();
+
+ HttpUrl baseUrl = mockWebServer.url("");
+ SorobanServer server = new SorobanServer(baseUrl.toString());
+
+ SorobanAuthorizationEntries result =
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ CLIENT_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ WEB_AUTH_CONTRACT,
+ Network.TESTNET,
+ null,
+ null);
+
+ assertNotNull(result);
+ assertTrue(result.getSorobanAuthorizationEntries().length >= 2);
+
+ mockWebServer.close();
+ server.close();
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesClientDomainWithoutAccount()
+ throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+
+ try (MockWebServer mockWebServer = new MockWebServer()) {
+ mockWebServer.start();
+ HttpUrl baseUrl = mockWebServer.url("");
+ try (SorobanServer server = new SorobanServer(baseUrl.toString())) {
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ CLIENT_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ WEB_AUTH_CONTRACT,
+ Network.TESTNET,
+ null,
+ null,
+ CLIENT_DOMAIN,
+ null);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "clientDomain and clientDomainAccountId must both be provided or both be null"));
+ }
+ }
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesClientDomainAccountWithoutDomain()
+ throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+
+ try (MockWebServer mockWebServer = new MockWebServer()) {
+ mockWebServer.start();
+ HttpUrl baseUrl = mockWebServer.url("");
+ try (SorobanServer server = new SorobanServer(baseUrl.toString())) {
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ CLIENT_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ WEB_AUTH_CONTRACT,
+ Network.TESTNET,
+ null,
+ null,
+ null,
+ CLIENT_DOMAIN_ACCOUNT);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(
+ e.getMessage()
+ .contains(
+ "clientDomain and clientDomainAccountId must both be provided or both be null"));
+ }
+ }
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesInvalidClientContractId() throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+
+ try (MockWebServer mockWebServer = new MockWebServer()) {
+ mockWebServer.start();
+ HttpUrl baseUrl = mockWebServer.url("");
+ try (SorobanServer server = new SorobanServer(baseUrl.toString())) {
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ "invalid_contract_id",
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ WEB_AUTH_CONTRACT,
+ Network.TESTNET,
+ null,
+ null);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("is not a valid contract id"));
+ }
+ }
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesInvalidWebAuthContractId() throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+
+ try (MockWebServer mockWebServer = new MockWebServer()) {
+ mockWebServer.start();
+ HttpUrl baseUrl = mockWebServer.url("");
+ try (SorobanServer server = new SorobanServer(baseUrl.toString())) {
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ CLIENT_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ "invalid_contract_id",
+ Network.TESTNET,
+ null,
+ null);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("is not a valid contract id"));
+ }
+ }
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesSimulationError() throws IOException {
+ KeyPair serverSigner = KeyPair.random();
+
+ String mockResponse =
+ new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/sep45/simulate_transaction_build_error.json")));
+
+ try (MockWebServer mockWebServer = new MockWebServer()) {
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse));
+ mockWebServer.start();
+
+ HttpUrl baseUrl = mockWebServer.url("");
+ try (SorobanServer server = new SorobanServer(baseUrl.toString())) {
+ Sep45Challenge.buildChallengeAuthorizationEntries(
+ server,
+ serverSigner,
+ CLIENT_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ WEB_AUTH_CONTRACT,
+ Network.TESTNET,
+ null,
+ null);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Transaction simulation failed"));
+ assertTrue(e.getMessage().contains("HostError: Error(WasmVm, MissingValue)"));
+ }
+ }
+ }
+
+ @Test
+ public void testVerifyChallengeAuthorizationEntriesSuccess() throws IOException {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String mockResponse =
+ new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/sep45/simulate_transaction_success.json")));
+
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse));
+ mockWebServer.start();
+
+ HttpUrl baseUrl = mockWebServer.url("");
+ SorobanServer server = new SorobanServer(baseUrl.toString());
+
+ Sep45Challenge.ChallengeAuthorizationEntries result =
+ Sep45Challenge.verifyChallengeAuthorizationEntries(
+ server,
+ xdr,
+ SERVER_ACCOUNT,
+ WEB_AUTH_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ Network.TESTNET);
+
+ assertEquals(CLIENT_CONTRACT, result.getClientContractId());
+ assertEquals(SERVER_ACCOUNT, result.getServerAccountId());
+
+ mockWebServer.close();
+ server.close();
+ }
+
+ @Test
+ public void testVerifyChallengeAuthorizationEntriesFailed() throws IOException {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String mockResponse =
+ new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/sep45/simulate_transaction_failed.json")));
+
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse));
+ mockWebServer.start();
+
+ HttpUrl baseUrl = mockWebServer.url("");
+ SorobanServer server = new SorobanServer(baseUrl.toString());
+
+ try {
+ Sep45Challenge.verifyChallengeAuthorizationEntries(
+ server,
+ xdr,
+ SERVER_ACCOUNT,
+ WEB_AUTH_CONTRACT,
+ HOME_DOMAIN,
+ WEB_AUTH_DOMAIN,
+ Network.TESTNET);
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
+ assertTrue(e.getMessage().contains("Signature verification failed"));
+ }
+
+ mockWebServer.close();
+ server.close();
+ }
+
+ @Test
+ public void testVerifyChallengeAuthorizationEntriesWithMultipleHomeDomains() throws IOException {
+ List entries = buildValidEntries(false);
+ String xdr = authorizationEntriesToXdr(entries);
+
+ String mockResponse =
+ new String(
+ Files.readAllBytes(
+ Paths.get("src/test/resources/sep45/simulate_transaction_success.json")));
+
+ MockWebServer mockWebServer = new MockWebServer();
+ mockWebServer.enqueue(new MockResponse().setResponseCode(200).setBody(mockResponse));
+ mockWebServer.start();
+
+ HttpUrl baseUrl = mockWebServer.url("");
+ SorobanServer server = new SorobanServer(baseUrl.toString());
+
+ String[] homeDomains = new String[] {"other.com", HOME_DOMAIN};
+
+ Sep45Challenge.ChallengeAuthorizationEntries result =
+ Sep45Challenge.verifyChallengeAuthorizationEntries(
+ server,
+ xdr,
+ SERVER_ACCOUNT,
+ WEB_AUTH_CONTRACT,
+ homeDomains,
+ WEB_AUTH_DOMAIN,
+ Network.TESTNET);
+
+ assertEquals(HOME_DOMAIN, result.getHomeDomain());
+
+ mockWebServer.close();
+ server.close();
+ }
+
+ /**
+ * Builds entries with custom args map for testing validation errors.
+ *
+ * @param argsMap the custom args map
+ * @return XDR string of the entries
+ */
+ private String buildEntriesWithCustomArgs(LinkedHashMap argsMap) {
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ SorobanAuthorizedInvocation invocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ List entries = new ArrayList<>();
+ entries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ entries.add(buildAuthorizationEntry(SERVER_ACCOUNT, invocation));
+
+ return authorizationEntriesToXdr(entries);
+ }
+
+ /**
+ * Builds a mock simulate transaction response.
+ *
+ * @param serverAccountId the server account ID
+ * @param includeClientDomain whether to include client domain
+ * @return JSON string of the mock response
+ */
+ private String buildMockSimulateResponse(String serverAccountId, boolean includeClientDomain) {
+ // Build mock authorization entries for the response
+ List mockEntries = new ArrayList<>();
+
+ // Build invocation with the server account
+ LinkedHashMap argsMap = new LinkedHashMap<>();
+ argsMap.put(Scv.toSymbol("account"), Scv.toString(CLIENT_CONTRACT));
+ if (includeClientDomain) {
+ argsMap.put(Scv.toSymbol("client_domain"), Scv.toString(CLIENT_DOMAIN));
+ argsMap.put(Scv.toSymbol("client_domain_account"), Scv.toString(CLIENT_DOMAIN_ACCOUNT));
+ }
+ argsMap.put(Scv.toSymbol("home_domain"), Scv.toString(HOME_DOMAIN));
+ argsMap.put(Scv.toSymbol("nonce"), Scv.toString(NONCE));
+ argsMap.put(Scv.toSymbol("web_auth_domain"), Scv.toString(WEB_AUTH_DOMAIN));
+ argsMap.put(Scv.toSymbol("web_auth_domain_account"), Scv.toString(serverAccountId));
+
+ InvokeContractArgs contractArgs =
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(WEB_AUTH_CONTRACT).toSCAddress())
+ .functionName(new SCSymbol(new XdrString(Sep45Challenge.WEB_AUTH_VERIFY_FUNCTION_NAME)))
+ .args(new SCVal[] {Scv.toMap(argsMap)})
+ .build();
+
+ SorobanAuthorizedFunction function =
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(contractArgs)
+ .build();
+
+ SorobanAuthorizedInvocation invocation =
+ SorobanAuthorizedInvocation.builder()
+ .function(function)
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+
+ // Client entry
+ mockEntries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ // Server entry
+ mockEntries.add(buildAuthorizationEntry(serverAccountId, invocation));
+ if (includeClientDomain) {
+ mockEntries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation));
+ }
+
+ // Convert entries to XDR base64 strings
+ List authXdrList = new ArrayList<>();
+ for (SorobanAuthorizationEntry entry : mockEntries) {
+ try {
+ authXdrList.add(entry.toXdrBase64());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ StringBuilder authArrayJson = new StringBuilder("[");
+ for (int i = 0; i < authXdrList.size(); i++) {
+ authArrayJson.append("\"").append(authXdrList.get(i)).append("\"");
+ if (i < authXdrList.size() - 1) {
+ authArrayJson.append(",");
+ }
+ }
+ authArrayJson.append("]");
+
+ return "{\n"
+ + " \"jsonrpc\": \"2.0\",\n"
+ + " \"id\": 1,\n"
+ + " \"result\": {\n"
+ + " \"transactionData\": \"AAAAAAAAAAIAAAAGAAAAAem354u9STQWq5b3Ed1j9tOemvL7xV0NPwhn4gXg0AP8AAAAFAAAAAEAAAAH8dTe2OoI0BnhlDbH0fWvXmvprkBvBAgKIcL9busuuMEAAAABAAAABgAAAAHpt+eLvUk0FquW9xHdY/bTnpry+8VdDT8IZ+IF4NAD/AAAABAAAAABAAAAAgAAAA8AAAAHQ291bnRlcgAAAAASAAAAAAAAAABYt8SiyPKXqo89JHEoH9/M7K/kjlZjMT7BjhKnPsqYoQAAAAEAHifGAAAFlAAAAIgAAAAAAAAAAg==\",\n"
+ + " \"minResourceFee\": \"58181\",\n"
+ + " \"events\": [],\n"
+ + " \"results\": [{\"auth\": "
+ + authArrayJson
+ + ", \"xdr\": \"AAAAAwAAABQ=\"}],\n"
+ + " \"latestLedger\": 14245\n"
+ + " }\n"
+ + "}";
+ }
+
+ /**
+ * Converts a list of {@link SorobanAuthorizationEntry} to a base64-encoded XDR string.
+ *
+ * @param entries The list of authorization entries to encode.
+ * @return The base64-encoded XDR string.
+ */
+ public static String authorizationEntriesToXdr(
+ @NonNull Collection entries) {
+ org.stellar.sdk.xdr.SorobanAuthorizationEntries xdrEntries =
+ new org.stellar.sdk.xdr.SorobanAuthorizationEntries();
+ xdrEntries.setSorobanAuthorizationEntries(entries.toArray(new SorobanAuthorizationEntry[0]));
+ try {
+ return xdrEntries.toXdrBase64();
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Failed to encode authorization entries", e);
+ }
+ }
+}
From 363c4f03c24ee632fabf584b080ac56e8a22fc46 Mon Sep 17 00:00:00 2001
From: Jun Luo <4catcode@gmail.com>
Date: Wed, 24 Dec 2025 16:09:54 +0800
Subject: [PATCH 2/4] feat: add SEP-0045 support.
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55ff40e82..78b42a7f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### Update:
- feat: add `AUTH_CLAWBACK_ENABLED_FLAG` to `AccountFlag`.
- fix: remove `diagnosticEventsXdr` from `org.stellar.sdk.responses.sorobanrpc.Events`. Use `GetTransactionResponse.diagnosticEventsXdr` and `GetTransactionsResponse.Transaction.diagnosticEventsXdr` instead. ([#744](https://github.com/stellar/java-stellar-sdk/pull/744))
+- feat: add [SEP-45](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0045.md) (Stellar Web Authentication for Contract Accounts) support. Check `Sep45Challenge` class for more details. ([#746](https://github.com/stellar/java-stellar-sdk/pull/746))
## 2.1.0
From 7c8a68b265bb6312c6dce3aeb864dd080bf10cf9 Mon Sep 17 00:00:00 2001
From: Jun Luo <4catcode@gmail.com>
Date: Thu, 25 Dec 2025 18:04:31 +0800
Subject: [PATCH 3/4] wip
---
.../sep45/simulate_transaction_build_error.json | 12 ++++++++++++
.../resources/sep45/simulate_transaction_failed.json | 8 ++++++++
.../sep45/simulate_transaction_success.json | 11 +++++++++++
3 files changed, 31 insertions(+)
create mode 100644 src/test/resources/sep45/simulate_transaction_build_error.json
create mode 100644 src/test/resources/sep45/simulate_transaction_failed.json
create mode 100644 src/test/resources/sep45/simulate_transaction_success.json
diff --git a/src/test/resources/sep45/simulate_transaction_build_error.json b/src/test/resources/sep45/simulate_transaction_build_error.json
new file mode 100644
index 000000000..82f81d4aa
--- /dev/null
+++ b/src/test/resources/sep45/simulate_transaction_build_error.json
@@ -0,0 +1,12 @@
+{
+ "jsonrpc": "2.0",
+ "id": "227dedbc6e564f9d80c40ffcc5805138",
+ "result": {
+ "error": "HostError: Error(WasmVm, MissingValue)\n\nEvent log (newest first):\n 0: [Diagnostic Event] contract:CBCRYOUDISSUEJDJWYXUUWOFZCX24TMBPR2FFI2N7NV4LBD7RWQQ6CSS, topics:[error, Error(WasmVm, MissingValue)], data:[\"trying to invoke non-existent contract function\", web_auth_verify]\n 1: [Diagnostic Event] topics:[fn_call, CBCRYOUDISSUEJDJWYXUUWOFZCX24TMBPR2FFI2N7NV4LBD7RWQQ6CSS, web_auth_verify], data:{account: \"CBUCJMHBZHQ3EXQ2LMSFVZUWCPH7BCTCYGOQ6LIIO2OUVKU3XDDOO2HN\", client_domain: \"client.example.com\", client_domain_account: \"GAFQLAZMGGZT4KSDIGBEIC5IXOVH6ITIFKUQ5ZTDWP3MQWLGVJWTH3TX\", home_domain: \"example.com\", nonce: \"random-nonce\", web_auth_domain: \"auth.example.com\", web_auth_domain_account: \"GCSNRCUM6EDEKSSBQNIOP66ONIM26FVCYP3GHYGD4NR3DK4F635Z32WQ\"}\n",
+ "events": [
+ "AAAAAAAAAAAAAAAAAAAAAgAAAAAAAAADAAAADwAAAAdmbl9jYWxsAAAAAA0AAAAgRRw6g0SlQiRpti9KWcXIr65NgXx0UqNN+2vFhH+NoQ8AAAAPAAAAD3dlYl9hdXRoX3ZlcmlmeQAAAAARAAAAAQAAAAcAAAAPAAAAB2FjY291bnQAAAAADgAAADhDQlVDSk1IQlpIUTNFWFEyTE1TRlZaVVdDUEg3QkNUQ1lHT1E2TElJTzJPVVZLVTNYRERPTzJITgAAAA8AAAANY2xpZW50X2RvbWFpbgAAAAAAAA4AAAASY2xpZW50LmV4YW1wbGUuY29tAAAAAAAPAAAAFWNsaWVudF9kb21haW5fYWNjb3VudAAAAAAAAA4AAAA4R0FGUUxBWk1HR1pUNEtTRElHQkVJQzVJWE9WSDZJVElGS1VRNVpURFdQM01RV0xHVkpXVEgzVFgAAAAPAAAAC2hvbWVfZG9tYWluAAAAAA4AAAALZXhhbXBsZS5jb20AAAAADwAAAAVub25jZQAAAAAAAA4AAAAMcmFuZG9tLW5vbmNlAAAADwAAAA93ZWJfYXV0aF9kb21haW4AAAAADgAAABBhdXRoLmV4YW1wbGUuY29tAAAADwAAABd3ZWJfYXV0aF9kb21haW5fYWNjb3VudAAAAAAOAAAAOEdDU05SQ1VNNkVERUtTU0JRTklPUDY2T05JTTI2RlZDWVAzR0hZR0Q0TlIzREs0RjYzNVozMldR",
+ "AAAAAAAAAAAAAAABRRw6g0SlQiRpti9KWcXIr65NgXx0UqNN+2vFhH+NoQ8AAAACAAAAAAAAAAIAAAAPAAAABWVycm9yAAAAAAAAAgAAAAEAAAADAAAAEAAAAAEAAAACAAAADgAAAC90cnlpbmcgdG8gaW52b2tlIG5vbi1leGlzdGVudCBjb250cmFjdCBmdW5jdGlvbgAAAAAPAAAAD3dlYl9hdXRoX3ZlcmlmeQA="
+ ],
+ "latestLedger": 113558
+ }
+}
diff --git a/src/test/resources/sep45/simulate_transaction_failed.json b/src/test/resources/sep45/simulate_transaction_failed.json
new file mode 100644
index 000000000..10312cf81
--- /dev/null
+++ b/src/test/resources/sep45/simulate_transaction_failed.json
@@ -0,0 +1,8 @@
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "error": "Signature verification failed",
+ "latestLedger": 14245
+ }
+}
diff --git a/src/test/resources/sep45/simulate_transaction_success.json b/src/test/resources/sep45/simulate_transaction_success.json
new file mode 100644
index 000000000..c3daaa91b
--- /dev/null
+++ b/src/test/resources/sep45/simulate_transaction_success.json
@@ -0,0 +1,11 @@
+{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "result": {
+ "transactionData": "AAAAAAAAAAIAAAAGAAAAAem354u9STQWq5b3Ed1j9tOemvL7xV0NPwhn4gXg0AP8AAAAFAAAAAEAAAAH8dTe2OoI0BnhlDbH0fWvXmvprkBvBAgKIcL9busuuMEAAAABAAAABgAAAAHpt+eLvUk0FquW9xHdY/bTnpry+8VdDT8IZ+IF4NAD/AAAABAAAAABAAAAAgAAAA8AAAAHQ291bnRlcgAAAAASAAAAAAAAAABYt8SiyPKXqo89JHEoH9/M7K/kjlZjMT7BjhKnPsqYoQAAAAEAHifGAAAFlAAAAIgAAAAAAAAAAg==",
+ "minResourceFee": "58181",
+ "events": [],
+ "results": [],
+ "latestLedger": 14245
+ }
+}
From 7d686ff131aa6f2142bc80e93554c622343a4917 Mon Sep 17 00:00:00 2001
From: Jun Luo <4catcode@gmail.com>
Date: Thu, 25 Dec 2025 22:40:14 +0800
Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=8E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/main/java/org/stellar/sdk/Sep45Challenge.java | 6 +++---
src/test/java/org/stellar/sdk/Sep45ChallengeTest.java | 4 ++--
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/main/java/org/stellar/sdk/Sep45Challenge.java b/src/main/java/org/stellar/sdk/Sep45Challenge.java
index 5e32c5b51..30437e5ba 100644
--- a/src/main/java/org/stellar/sdk/Sep45Challenge.java
+++ b/src/main/java/org/stellar/sdk/Sep45Challenge.java
@@ -268,13 +268,13 @@ public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries(
@NonNull String[] homeDomains,
@NonNull String webAuthDomain) {
- if (homeDomains == null || homeDomains.length == 0) {
- throw new IllegalArgumentException(
+ if (homeDomains.length == 0) {
+ throw new InvalidSep45ChallengeException(
"At least one domain name must be included in homeDomains.");
}
// Validate server account ID
- if (StrKey.decodeVersionByte(serverAccountId) != StrKey.VersionByte.ACCOUNT_ID) {
+ if (!StrKey.isValidEd25519PublicKey(serverAccountId)) {
throw new InvalidSep45ChallengeException(
"serverAccountId: " + serverAccountId + " is not a valid account id");
}
diff --git a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
index 68b57d40b..6d3072ec4 100644
--- a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
+++ b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
@@ -841,8 +841,8 @@ public void testReadChallengeEmptyHomeDomains() {
try {
Sep45Challenge.readChallengeAuthorizationEntries(
xdr, SERVER_ACCOUNT, WEB_AUTH_CONTRACT, new String[] {}, WEB_AUTH_DOMAIN);
- fail("Expected IllegalArgumentException");
- } catch (IllegalArgumentException e) {
+ fail("Expected InvalidSep45ChallengeException");
+ } catch (InvalidSep45ChallengeException e) {
assertTrue(e.getMessage().contains("At least one domain name must be included"));
}
}