diff --git a/CHANGELOG.md b/CHANGELOG.md
index 65dbf86f0..b0b4dce83 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,27 @@
# Changelog
## Pending
+
+### Update
- chore: upgrade generated XDR definitions to Protocol 27.
+- feat: add CAP-0071 (Protocol 27) Soroban authorization support.
+ - New credential types (from the Protocol 27 XDR):
+ - `SOROBAN_CREDENTIALS_ADDRESS_V2` (CAP-71-02) — same fields as the legacy `ADDRESS`, but the signed payload is bound to the signer's address.
+ - `SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES` (CAP-71-01) — delegated / multi-party signing via a (possibly nested) tree of delegate signatures.
+ - `Auth.authorizeEntry`:
+ - Signs all three address-based credential types, selecting the signature payload from the credential type: legacy `ADDRESS` keeps the non-address-bound preimage; `ADDRESS_V2` and `ADDRESS_WITH_DELEGATES` sign the new address-bound `ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS` preimage.
+ - Gains an optional `forAddress` parameter that writes the signature into a specific (possibly nested) delegate node. All signers of one delegated entry sign the same payload, bound to the top-level address.
+ - `Auth.authorizeInvocation`:
+ - Still builds legacy `ADDRESS` entries by default, so its output stays valid on every network regardless of protocol 27 activation.
+ - New `credentialsType` overloads opt in to `ADDRESS_V2`. The default will flip to V2 once Protocol 28 makes it mandatory.
+ - New helpers in `Auth`:
+ - `buildAuthorizationEntryPreimage` — builds the exact payload a signer must sign for a given entry.
+ - `buildWithDelegatesEntry` / `Auth.DelegateSignature` — wrap an `ADDRESS`/`ADDRESS_V2` entry together with delegate signers, sorting each delegates level by address and rejecting duplicates, as the protocol requires.
+ - `getAddressCredentials` — extracts the inner `SorobanAddressCredentials` from any address-based credential type.
+ - `AssembledTransaction`: `signAuthEntries` and `needsNonInvokerSigningBy` handle all address-based credential types.
+ - SEP-45 (`Sep45Challenge`):
+ - Challenge parsing and building accept `ADDRESS_V2` entries in addition to the legacy type; delegated entries are rejected.
+ - Challenge building fails fast on unsupported credential types instead of passing the entries through unsigned.
## 3.1.0
diff --git a/src/main/java/org/stellar/sdk/Auth.java b/src/main/java/org/stellar/sdk/Auth.java
index 179f3ab26..faf14ab5a 100644
--- a/src/main/java/org/stellar/sdk/Auth.java
+++ b/src/main/java/org/stellar/sdk/Auth.java
@@ -2,21 +2,30 @@
import java.io.IOException;
import java.security.SecureRandom;
+import java.util.AbstractMap;
+import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import lombok.Builder;
import lombok.Value;
+import org.jetbrains.annotations.Nullable;
import org.stellar.sdk.exception.UnexpectedException;
import org.stellar.sdk.scval.Scv;
import org.stellar.sdk.xdr.EnvelopeType;
import org.stellar.sdk.xdr.Hash;
import org.stellar.sdk.xdr.HashIDPreimage;
import org.stellar.sdk.xdr.Int64;
+import org.stellar.sdk.xdr.SCAddress;
import org.stellar.sdk.xdr.SCVal;
import org.stellar.sdk.xdr.SorobanAddressCredentials;
+import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates;
import org.stellar.sdk.xdr.SorobanAuthorizationEntry;
import org.stellar.sdk.xdr.SorobanAuthorizedInvocation;
import org.stellar.sdk.xdr.SorobanCredentials;
import org.stellar.sdk.xdr.SorobanCredentialsType;
+import org.stellar.sdk.xdr.SorobanDelegateSignature;
import org.stellar.sdk.xdr.Uint32;
import org.stellar.sdk.xdr.XdrUnsignedInteger;
@@ -52,13 +61,46 @@ public class Auth {
*/
public static SorobanAuthorizationEntry authorizeEntry(
String entry, KeyPair signer, Long validUntilLedgerSeq, Network network) {
+ return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null);
+ }
+
+ /**
+ * Actually authorizes an existing authorization entry using the given the credentials and
+ * expiration details, returning a signed copy.
+ *
+ *
This "fills out" the authorization entry with a signature, indicating to the {@link
+ * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that:
+ *
+ *
until a particular ledger sequence is reached.
*
*
- * @param entry a base64 encoded unsigned Soroban authorization entry
+ * @param entry an unsigned Soroban authorization entry
* @param signer a {@link KeyPair} which should correspond to the address in the `entry`
* @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
* authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
@@ -85,6 +127,39 @@ public static SorobanAuthorizationEntry authorizeEntry(
*/
public static SorobanAuthorizationEntry authorizeEntry(
SorobanAuthorizationEntry entry, KeyPair signer, Long validUntilLedgerSeq, Network network) {
+ return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null);
+ }
+
+ /**
+ * Actually authorizes an existing authorization entry using the given the credentials and
+ * expiration details, returning a signed copy.
+ *
+ * This "fills out" the authorization entry with a signature, indicating to the {@link
+ * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that:
+ *
+ *
+ * - a particular identity (i.e. signing {@link KeyPair} or {@link Signer})
+ *
- approving the execution of an invocation tree (i.e. a simulation-acquired {@link
+ * SorobanAuthorizedInvocation} or otherwise built)
+ *
- on a particular network (uniquely identified by its passphrase, see {@link Network})
+ *
- until a particular ledger sequence is reached.
+ *
+ *
+ * @param entry an unsigned Soroban authorization entry
+ * @param signer a {@link KeyPair} which should correspond to the address in the `entry`
+ * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
+ * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
+ * @param network the network is incorprated into the signature
+ * @param forAddress which credential node the signature should be written to, see {@link
+ * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)}
+ * @return a signed Soroban authorization entry
+ */
+ public static SorobanAuthorizationEntry authorizeEntry(
+ SorobanAuthorizationEntry entry,
+ KeyPair signer,
+ Long validUntilLedgerSeq,
+ Network network,
+ @Nullable String forAddress) {
Signer entrySigner =
preimage -> {
byte[] data;
@@ -98,7 +173,7 @@ public static SorobanAuthorizationEntry authorizeEntry(
return new Signature(signer.getAccountId(), signature);
};
- return authorizeEntry(entry, entrySigner, validUntilLedgerSeq, network);
+ return authorizeEntry(entry, entrySigner, validUntilLedgerSeq, network, forAddress);
}
/**
@@ -126,13 +201,47 @@ public static SorobanAuthorizationEntry authorizeEntry(
*/
public static SorobanAuthorizationEntry authorizeEntry(
String entry, Signer signer, Long validUntilLedgerSeq, Network network) {
+ return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null);
+ }
+
+ /**
+ * Actually authorizes an existing authorization entry using the given the credentials and
+ * expiration details, returning a signed copy.
+ *
+ * This "fills out" the authorization entry with a signature, indicating to the {@link
+ * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that:
+ *
+ *
+ * - a particular identity (i.e. signing {@link KeyPair} or {@link Signer})
+ *
- approving the execution of an invocation tree (i.e. a simulation-acquired {@link
+ * SorobanAuthorizedInvocation} or otherwise built)
+ *
- on a particular network (uniquely identified by its passphrase, see {@link Network})
+ *
- until a particular ledger sequence is reached.
+ *
+ *
+ * @param entry a base64 encoded unsigned Soroban authorization entry
+ * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the
+ * signature of the hash of the raw payload bytes, see {@link Signer}
+ * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
+ * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
+ * @param network the network is incorprated into the signature
+ * @param forAddress which credential node the signature should be written to, see {@link
+ * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)}
+ * @return a signed Soroban authorization entry
+ */
+ public static SorobanAuthorizationEntry authorizeEntry(
+ String entry,
+ Signer signer,
+ Long validUntilLedgerSeq,
+ Network network,
+ @Nullable String forAddress) {
SorobanAuthorizationEntry entryXdr;
try {
entryXdr = SorobanAuthorizationEntry.fromXdrBase64(entry);
} catch (IOException e) {
throw new IllegalArgumentException("Unable to convert entry to SorobanAuthorizationEntry", e);
}
- return authorizeEntry(entryXdr, signer, validUntilLedgerSeq, network);
+ return authorizeEntry(entryXdr, signer, validUntilLedgerSeq, network, forAddress);
}
/**
@@ -160,6 +269,55 @@ public static SorobanAuthorizationEntry authorizeEntry(
*/
public static SorobanAuthorizationEntry authorizeEntry(
SorobanAuthorizationEntry entry, Signer signer, Long validUntilLedgerSeq, Network network) {
+ return authorizeEntry(entry, signer, validUntilLedgerSeq, network, null);
+ }
+
+ /**
+ * Actually authorizes an existing authorization entry using the given the credentials and
+ * expiration details, returning a signed copy.
+ *
+ * This "fills out" the authorization entry with a signature, indicating to the {@link
+ * org.stellar.sdk.operations.InvokeHostFunctionOperation} it's attached to that:
+ *
+ *
+ * - a particular identity (i.e. signing {@link KeyPair} or {@link Signer})
+ *
- approving the execution of an invocation tree (i.e. a simulation-acquired {@link
+ * SorobanAuthorizedInvocation} or otherwise built)
+ *
- on a particular network (uniquely identified by its passphrase, see {@link Network})
+ *
- until a particular ledger sequence is reached.
+ *
+ *
+ * All address-based credential types are supported: {@code SOROBAN_CREDENTIALS_ADDRESS},
+ * {@code SOROBAN_CREDENTIALS_ADDRESS_V2}, and {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES}.
+ * The signature payload differs per type, see {@link
+ * Auth#buildAuthorizationEntryPreimage(SorobanAuthorizationEntry, long, Network)}. Source-account
+ * credentials are returned unchanged (they are covered by the transaction envelope signature).
+ *
+ * @param entry an unsigned Soroban authorization entry
+ * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the
+ * signature of the hash of the raw payload bytes, see {@link Signer}
+ * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
+ * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
+ * @param network the network is incorprated into the signature
+ * @param forAddress which credential node the signature should be written to. Only relevant for
+ * {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES}, where a single entry can be signed by
+ * the top-level account and/or any of its (possibly nested) delegates. Per CAP-71-01 every
+ * one of these signers signs the same payload (bound to the top-level address), so
+ * the signature produced here is written to whichever node(s) carry {@code forAddress}.
+ * Because that shared payload commits to {@code validUntilLedgerSeq}, every signer of one
+ * entry must use the same value — signing with a different value invalidates the signatures
+ * collected so far. When {@code null}, the signature is written to the top-level credentials,
+ * which preserves the behavior for {@code SOROBAN_CREDENTIALS_ADDRESS} / {@code
+ * SOROBAN_CREDENTIALS_ADDRESS_V2} and for accounts whose signing key differs from the
+ * credential address (e.g. multisig).
+ * @return a signed Soroban authorization entry
+ */
+ public static SorobanAuthorizationEntry authorizeEntry(
+ SorobanAuthorizationEntry entry,
+ Signer signer,
+ Long validUntilLedgerSeq,
+ Network network,
+ @Nullable String forAddress) {
SorobanAuthorizationEntry clone;
try {
clone = SorobanAuthorizationEntry.fromXdrByteArray(entry.toXdrByteArray());
@@ -168,25 +326,23 @@ public static SorobanAuthorizationEntry authorizeEntry(
}
if (clone.getCredentials().getDiscriminant()
- != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) {
+ == SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT) {
return clone;
}
- SorobanAddressCredentials addressCredentials = clone.getCredentials().getAddress();
+ SorobanAddressCredentials addressCredentials = getAddressCredentials(clone.getCredentials());
+ if (addressCredentials == null) {
+ throw new IllegalArgumentException(
+ "Unsupported credentials type: " + clone.getCredentials().getDiscriminant());
+ }
+
+ // Set the expiration before building the preimage, so the hash that gets signed commits to
+ // the same expiration ledger stored in the credentials. Otherwise the network reconstructs
+ // the preimage from the (updated) credentials and the signature no longer matches.
addressCredentials.setSignatureExpirationLedger(
new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)));
- HashIDPreimage preimage =
- HashIDPreimage.builder()
- .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION)
- .sorobanAuthorization(
- HashIDPreimage.HashIDPreimageSorobanAuthorization.builder()
- .networkID(new Hash(network.getNetworkId()))
- .nonce(addressCredentials.getNonce())
- .invocation(clone.getRootInvocation())
- .signatureExpirationLedger(addressCredentials.getSignatureExpirationLedger())
- .build())
- .build();
+ HashIDPreimage preimage = buildAuthorizationEntryPreimage(clone, validUntilLedgerSeq, network);
Signature signature = signer.sign(preimage);
@@ -213,7 +369,22 @@ public static SorobanAuthorizationEntry authorizeEntry(
put(Scv.toSymbol("signature"), Scv.toBytes(signature.getSignature()));
}
});
- addressCredentials.setSignature(Scv.toVec(Collections.singleton(sigScVal)));
+ SCVal signatureScVal = Scv.toVec(Collections.singleton(sigScVal));
+
+ // CAP-71-01: the signature payload is shared across the top-level address and every
+ // (possibly nested) delegate, so this signer's signature is written to whichever credential
+ // node(s) carry `forAddress`. When no `forAddress` is given the signature goes to the
+ // top-level credentials.
+ if (forAddress == null) {
+ addressCredentials.setSignature(signatureScVal);
+ } else {
+ SCAddress forScAddress = new Address(forAddress).toSCAddress();
+ int filled = fillMatchingSignatureNodes(clone.getCredentials(), forScAddress, signatureScVal);
+ if (filled == 0) {
+ throw new IllegalArgumentException(
+ "the authorization entry has no credential node for address " + forAddress);
+ }
+ }
return clone;
}
@@ -231,6 +402,12 @@ public static SorobanAuthorizationEntry authorizeEntry(
*
This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in
* place".
*
+ *
The returned entry uses legacy {@code SOROBAN_CREDENTIALS_ADDRESS} credentials, which are
+ * valid on every network. To opt in to the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2}
+ * credentials (CAP-71-02, requires a protocol 27 network), use {@link
+ * Auth#authorizeInvocation(KeyPair, Long, SorobanAuthorizedInvocation, Network,
+ * SorobanCredentialsType)}. The default will flip to V2 once protocol 28 makes it mandatory.
+ *
* @param signer a {@link KeyPair} used to sign the entry
* @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
* authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
@@ -244,6 +421,47 @@ public static SorobanAuthorizationEntry authorizeInvocation(
Long validUntilLedgerSeq,
SorobanAuthorizedInvocation invocation,
Network network) {
+ return authorizeInvocation(
+ signer,
+ validUntilLedgerSeq,
+ invocation,
+ network,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS);
+ }
+
+ /**
+ * This builds an entry from scratch, allowing you to express authorization as a function of:
+ *
+ *
+ * - a particular identity (i.e. signing {@link KeyPair} or {@link Signer})
+ *
- approving the execution of an invocation tree (i.e. a simulation-acquired {@link
+ * SorobanAuthorizedInvocation} or otherwise built)
+ *
- on a particular network (uniquely identified by its passphrase, see {@link Network})
+ *
- until a particular ledger sequence is reached.
+ *
+ *
+ * This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in
+ * place".
+ *
+ * @param signer a {@link KeyPair} used to sign the entry
+ * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
+ * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
+ * @param invocation invocation the invocation tree that we're authorizing (likely, this comes
+ * from transaction simulation)
+ * @param network the network is incorprated into the signature
+ * @param credentialsType the credential type for the new entry, either the legacy {@code
+ * SOROBAN_CREDENTIALS_ADDRESS} (the default of the shorter overloads, valid on every network)
+ * or the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2} (CAP-71-02, requires a protocol
+ * 27 network). To build a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entry, use
+ * {@link Auth#buildWithDelegatesEntry(SorobanAuthorizationEntry, long, List, SCVal)} instead
+ * @return a signed Soroban authorization entry
+ */
+ public static SorobanAuthorizationEntry authorizeInvocation(
+ KeyPair signer,
+ Long validUntilLedgerSeq,
+ SorobanAuthorizedInvocation invocation,
+ Network network,
+ SorobanCredentialsType credentialsType) {
Signer entrySigner =
preimage -> {
try {
@@ -255,7 +473,12 @@ public static SorobanAuthorizationEntry authorizeInvocation(
}
};
return authorizeInvocation(
- entrySigner, signer.getAccountId(), validUntilLedgerSeq, invocation, network);
+ entrySigner,
+ signer.getAccountId(),
+ validUntilLedgerSeq,
+ invocation,
+ network,
+ credentialsType);
}
/**
@@ -272,6 +495,12 @@ public static SorobanAuthorizationEntry authorizeInvocation(
*
This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in
* place".
*
+ *
The returned entry uses legacy {@code SOROBAN_CREDENTIALS_ADDRESS} credentials, which are
+ * valid on every network. To opt in to the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2}
+ * credentials (CAP-71-02, requires a protocol 27 network), use {@link
+ * Auth#authorizeInvocation(Signer, String, Long, SorobanAuthorizedInvocation, Network,
+ * SorobanCredentialsType)}. The default will flip to V2 once protocol 28 makes it mandatory.
+ *
* @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the
* signature of the hash of the raw payload bytes, see {@link Signer}
* @param publicKey the public identity of the signer
@@ -288,26 +517,371 @@ public static SorobanAuthorizationEntry authorizeInvocation(
Long validUntilLedgerSeq,
SorobanAuthorizedInvocation invocation,
Network network) {
+ return authorizeInvocation(
+ signer,
+ publicKey,
+ validUntilLedgerSeq,
+ invocation,
+ network,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS);
+ }
+
+ /**
+ * This builds an entry from scratch, allowing you to express authorization as a function of:
+ *
+ *
+ * - a particular identity (i.e. signing {@link KeyPair} or {@link Signer})
+ *
- approving the execution of an invocation tree (i.e. a simulation-acquired {@link
+ * SorobanAuthorizedInvocation} or otherwise built)
+ *
- on a particular network (uniquely identified by its passphrase, see {@link Network})
+ *
- until a particular ledger sequence is reached.
+ *
+ *
+ * This is in contrast to {@link Auth#authorizeEntry}, which signs an existing entry "in
+ * place".
+ *
+ * @param signer A function which takes a payload (a {@link HashIDPreimage}) and returns the
+ * signature of the hash of the raw payload bytes, see {@link Signer}
+ * @param publicKey the public identity of the signer
+ * @param validUntilLedgerSeq the (exclusive) future ledger sequence number until which this
+ * authorization entry should be valid (if `currentLedgerSeq==validUntil`, this is expired)
+ * @param invocation invocation the invocation tree that we're authorizing (likely, this comes
+ * from transaction simulation)
+ * @param network the network is incorprated into the signature
+ * @param credentialsType the credential type for the new entry, either the legacy {@code
+ * SOROBAN_CREDENTIALS_ADDRESS} (the default of the shorter overloads, valid on every network)
+ * or the address-bound {@code SOROBAN_CREDENTIALS_ADDRESS_V2} (CAP-71-02, requires a protocol
+ * 27 network). To build a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entry, use
+ * {@link Auth#buildWithDelegatesEntry(SorobanAuthorizationEntry, long, List, SCVal)} instead
+ * @return a signed Soroban authorization entry
+ */
+ public static SorobanAuthorizationEntry authorizeInvocation(
+ Signer signer,
+ String publicKey,
+ Long validUntilLedgerSeq,
+ SorobanAuthorizedInvocation invocation,
+ Network network,
+ SorobanCredentialsType credentialsType) {
long nonce = new SecureRandom().nextLong();
+ SorobanAddressCredentials addressCredentials =
+ SorobanAddressCredentials.builder()
+ .address(new Address(publicKey).toSCAddress())
+ .nonce(new Int64(nonce))
+ .signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
+ .signature(Scv.toVoid())
+ .build();
+ SorobanCredentials credentials;
+ if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) {
+ credentials =
+ SorobanCredentials.builder()
+ .discriminant(credentialsType)
+ .address(addressCredentials)
+ .build();
+ } else if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) {
+ credentials =
+ SorobanCredentials.builder()
+ .discriminant(credentialsType)
+ .addressV2(addressCredentials)
+ .build();
+ } else {
+ throw new IllegalArgumentException(
+ "credentialsType must be SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2; use buildWithDelegatesEntry to build SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES entries");
+ }
SorobanAuthorizationEntry entry =
SorobanAuthorizationEntry.builder()
- .credentials(
- SorobanCredentials.builder()
- .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)
- .address(
- SorobanAddressCredentials.builder()
- .address(new Address(publicKey).toSCAddress())
- .nonce(new Int64(nonce))
- .signatureExpirationLedger(
- new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
- .signature(Scv.toVoid())
- .build())
- .build())
+ .credentials(credentials)
.rootInvocation(invocation)
.build();
return authorizeEntry(entry, signer, validUntilLedgerSeq, network);
}
+ /**
+ * Extracts the {@link SorobanAddressCredentials} from any address-based Soroban credential,
+ * regardless of which credential type variant is used.
+ *
+ *
This unifies access across {@code SOROBAN_CREDENTIALS_ADDRESS}, {@code
+ * SOROBAN_CREDENTIALS_ADDRESS_V2} (which carries identical fields but binds the address into the
+ * signature payload, CAP-71-02), and {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} (which
+ * wraps the same address credentials alongside a set of delegate signatures, CAP-71-01).
+ *
+ * @param credentials the credentials to inspect
+ * @return the inner address credentials, or {@code null} for source-account credentials (which
+ * carry no address payload)
+ */
+ @Nullable
+ public static SorobanAddressCredentials getAddressCredentials(SorobanCredentials credentials) {
+ switch (credentials.getDiscriminant()) {
+ case SOROBAN_CREDENTIALS_ADDRESS:
+ return credentials.getAddress();
+ case SOROBAN_CREDENTIALS_ADDRESS_V2:
+ return credentials.getAddressV2();
+ case SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES:
+ return credentials.getAddressWithDelegates().getAddressCredentials();
+ default:
+ return null;
+ }
+ }
+
+ /**
+ * Builds the {@link HashIDPreimage} whose hash a signer must sign to authorize {@code entry}.
+ * This is the low-level signature payload used by {@link Auth#authorizeEntry}, exposed for
+ * callers that drive signing themselves — most notably for {@code
+ * SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES}, where the client (not simulation) decides which
+ * delegates sign and how.
+ *
+ *
For {@code SOROBAN_CREDENTIALS_ADDRESS} this is the legacy, non-address-bound {@code
+ * ENVELOPE_TYPE_SOROBAN_AUTHORIZATION} preimage. For {@code SOROBAN_CREDENTIALS_ADDRESS_V2} and
+ * {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} it is the address-bound {@code
+ * ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS} preimage (CAP-71). For the delegates variant
+ * this single payload — bound to the top-level address — is what the top-level account
+ * and every (nested) delegate each sign.
+ *
+ *
To get the raw bytes to sign, hash the XDR: {@code Util.hash(preimage.toXdrByteArray())}.
+ *
+ * @param entry the authorization entry to build the payload for
+ * @param validUntilLedgerSeq the expiration ledger committed into the payload (must match the
+ * {@code signatureExpirationLedger} on the credentials you submit)
+ * @param network the network whose id is mixed into the payload
+ * @return the preimage to hash and sign
+ * @throws IllegalArgumentException if {@code entry} carries source-account or otherwise
+ * non-address credentials
+ */
+ public static HashIDPreimage buildAuthorizationEntryPreimage(
+ SorobanAuthorizationEntry entry, long validUntilLedgerSeq, Network network) {
+ SorobanCredentials credentials = entry.getCredentials();
+ SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials);
+ if (addressCredentials == null) {
+ throw new IllegalArgumentException(
+ "cannot build a signature payload for credentials type: "
+ + credentials.getDiscriminant());
+ }
+
+ Uint32 signatureExpirationLedger = new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq));
+ switch (credentials.getDiscriminant()) {
+ // legacy address credentials are not address-bound
+ case SOROBAN_CREDENTIALS_ADDRESS:
+ return HashIDPreimage.builder()
+ .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION)
+ .sorobanAuthorization(
+ HashIDPreimage.HashIDPreimageSorobanAuthorization.builder()
+ .networkID(new Hash(network.getNetworkId()))
+ .nonce(addressCredentials.getNonce())
+ .signatureExpirationLedger(signatureExpirationLedger)
+ .invocation(entry.getRootInvocation())
+ .build())
+ .build();
+ // ADDRESS_V2 and ADDRESS_WITH_DELEGATES bind the address into the signed payload via the
+ // WithAddress preimage (CAP-71)
+ case SOROBAN_CREDENTIALS_ADDRESS_V2:
+ case SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES:
+ return HashIDPreimage.builder()
+ .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS)
+ .sorobanAuthorizationWithAddress(
+ HashIDPreimage.HashIDPreimageSorobanAuthorizationWithAddress.builder()
+ .networkID(new Hash(network.getNetworkId()))
+ .nonce(addressCredentials.getNonce())
+ .signatureExpirationLedger(signatureExpirationLedger)
+ .address(addressCredentials.getAddress())
+ .invocation(entry.getRootInvocation())
+ .build())
+ .build();
+ default:
+ throw new IllegalArgumentException(
+ "unsupported credentials type: " + credentials.getDiscriminant());
+ }
+ }
+
+ /**
+ * Builds a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} authorization entry (CAP-71-01) by
+ * wrapping the address credentials of an existing {@code SOROBAN_CREDENTIALS_ADDRESS} or {@code
+ * SOROBAN_CREDENTIALS_ADDRESS_V2} entry (e.g. one returned by simulation) together with a
+ * caller-provided set of delegate signers.
+ *
+ *
Simulation never emits the delegates variant on its own — which accounts use delegated
+ * authentication is account-specific policy known only to the client (much like a multisig
+ * policy). This helper just assembles the wrapper XDR; you supply the delegate tree (addresses
+ * and, optionally, signatures). To produce the signatures, build the shared payload with {@link
+ * Auth#buildAuthorizationEntryPreimage(SorobanAuthorizationEntry, long, Network)} on the returned
+ * entry and sign it, or fill each node afterwards with {@link
+ * Auth#authorizeEntry(SorobanAuthorizationEntry, Signer, Long, Network, String)} (passing the
+ * signer's address as {@code forAddress} and the same {@code validUntilLedgerSeq} as given here —
+ * the shared payload commits to it).
+ *
+ *
Which delegates must actually sign is decided by the top-level account contract: per
+ * CAP-71-01 the host verifies a listed delegate only when the account's {@code __check_auth}
+ * consumes it, so a delegate left with its void placeholder is valid as long as the account's
+ * policy does not require it.
+ *
+ *
Each delegates list (the top-level set and every {@code nestedDelegates}) is sorted by
+ * address in ascending order, and duplicate addresses within a list are rejected, as the protocol
+ * requires (CAP-71-01) — otherwise the host rejects the entry.
+ *
+ * @param entry an existing {@code SOROBAN_CREDENTIALS_ADDRESS} or {@code
+ * SOROBAN_CREDENTIALS_ADDRESS_V2} entry whose address credentials should be wrapped
+ * @param validUntilLedgerSeq the expiration ledger sequence stored on the top-level credentials
+ * @param delegates the delegate signers to attach
+ * @param signature the top-level account's signature, or {@code null} for an {@code SCV_VOID}
+ * placeholder, which is valid for accounts that authorize purely via delegated signers
+ * @return a new {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} authorization entry
+ * @throws IllegalArgumentException if {@code entry} does not carry {@code ADDRESS} / {@code
+ * ADDRESS_V2} credentials, or if any delegates list contains a duplicate address
+ */
+ public static SorobanAuthorizationEntry buildWithDelegatesEntry(
+ SorobanAuthorizationEntry entry,
+ long validUntilLedgerSeq,
+ List delegates,
+ @Nullable SCVal signature) {
+ SorobanCredentials credentials = entry.getCredentials();
+ if (credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS
+ && credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) {
+ throw new IllegalArgumentException(
+ "buildWithDelegatesEntry expects SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2 credentials, got "
+ + credentials.getDiscriminant());
+ }
+ SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials);
+
+ return SorobanAuthorizationEntry.builder()
+ .credentials(
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)
+ .addressWithDelegates(
+ SorobanAddressCredentialsWithDelegates.builder()
+ .addressCredentials(
+ SorobanAddressCredentials.builder()
+ .address(addressCredentials.getAddress())
+ .nonce(addressCredentials.getNonce())
+ .signatureExpirationLedger(
+ new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
+ .signature(signature != null ? signature : Scv.toVoid())
+ .build())
+ .delegates(buildDelegateNodes(delegates))
+ .build())
+ .build())
+ .rootInvocation(entry.getRootInvocation())
+ .build();
+ }
+
+ /**
+ * Recursively converts {@link DelegateSignature} descriptors into {@link
+ * SorobanDelegateSignature} nodes, sorting each level by address and rejecting duplicates
+ * (CAP-71-01).
+ */
+ private static SorobanDelegateSignature[] buildDelegateNodes(
+ @Nullable List delegates) {
+ if (delegates == null || delegates.isEmpty()) {
+ return new SorobanDelegateSignature[0];
+ }
+
+ // Sort keys are the XDR encoding of each address; serialize each address once up front
+ // instead of inside the comparator.
+ List> keyedNodes =
+ new ArrayList<>(delegates.size());
+ for (DelegateSignature delegate : delegates) {
+ SorobanDelegateSignature node =
+ SorobanDelegateSignature.builder()
+ .address(new Address(delegate.getAddress()).toSCAddress())
+ .signature(delegate.getSignature() != null ? delegate.getSignature() : Scv.toVoid())
+ .nestedDelegates(buildDelegateNodes(delegate.getNestedDelegates()))
+ .build();
+ keyedNodes.add(
+ new AbstractMap.SimpleImmutableEntry<>(scAddressXdrBytes(node.getAddress()), node));
+ }
+
+ keyedNodes.sort((a, b) -> compareBytes(a.getKey(), b.getKey()));
+
+ SorobanDelegateSignature[] nodes = new SorobanDelegateSignature[keyedNodes.size()];
+ for (int i = 0; i < keyedNodes.size(); i++) {
+ if (i > 0 && compareBytes(keyedNodes.get(i - 1).getKey(), keyedNodes.get(i).getKey()) == 0) {
+ throw new IllegalArgumentException(
+ "duplicate delegate address "
+ + Address.fromSCAddress(keyedNodes.get(i).getValue().getAddress()).toString());
+ }
+ nodes[i] = keyedNodes.get(i).getValue();
+ }
+
+ return nodes;
+ }
+
+ /**
+ * Writes {@code signature} to every credential node whose address equals {@code forAddress}: the
+ * top-level address credentials and, for the delegates variant, each (possibly nested) {@link
+ * SorobanDelegateSignature}. Returns the number of nodes filled.
+ */
+ private static int fillMatchingSignatureNodes(
+ SorobanCredentials credentials, SCAddress forAddress, SCVal signature) {
+ int filled = 0;
+ SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials);
+ if (addressCredentials != null && addressCredentials.getAddress().equals(forAddress)) {
+ addressCredentials.setSignature(signature);
+ filled++;
+ }
+ if (credentials.getDiscriminant()
+ == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) {
+ for (SorobanDelegateSignature delegate :
+ credentials.getAddressWithDelegates().getDelegates()) {
+ filled += fillMatchingDelegateNodes(delegate, forAddress, signature);
+ }
+ }
+ return filled;
+ }
+
+ private static int fillMatchingDelegateNodes(
+ SorobanDelegateSignature node, SCAddress forAddress, SCVal signature) {
+ int filled = 0;
+ if (node.getAddress().equals(forAddress)) {
+ node.setSignature(signature);
+ filled++;
+ }
+ if (node.getNestedDelegates() != null) {
+ for (SorobanDelegateSignature nested : node.getNestedDelegates()) {
+ filled += fillMatchingDelegateNodes(nested, forAddress, signature);
+ }
+ }
+ return filled;
+ }
+
+ private static byte[] scAddressXdrBytes(SCAddress address) {
+ try {
+ return address.toXdrByteArray();
+ } catch (IOException e) {
+ throw new UnexpectedException(e);
+ }
+ }
+
+ /** Compares two byte arrays lexicographically, treating bytes as unsigned. */
+ private static int compareBytes(byte[] a, byte[] b) {
+ int minLength = Math.min(a.length, b.length);
+ for (int i = 0; i < minLength; i++) {
+ int cmp = Integer.compare(a[i] & 0xff, b[i] & 0xff);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+ return Integer.compare(a.length, b.length);
+ }
+
+ /**
+ * A delegate signer to attach to a {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entry via
+ * {@link Auth#buildWithDelegatesEntry(SorobanAuthorizationEntry, long, List, SCVal)} (CAP-71-01).
+ */
+ @Value
+ @Builder
+ public static class DelegateSignature {
+ /** The delegate's address ({@code G...} account or {@code C...} contract). */
+ String address;
+
+ /**
+ * The delegate's signature value, or {@code null} for an {@code SCV_VOID} placeholder, which
+ * you can fill afterwards with {@link Auth#authorizeEntry(SorobanAuthorizationEntry, Signer,
+ * Long, Network, String)} (passing this address as {@code forAddress}) or by editing the entry
+ * directly.
+ */
+ @Nullable SCVal signature;
+
+ /** Signers this delegate in turn delegates to (recursive), or {@code null} for none. */
+ @Nullable List nestedDelegates;
+ }
+
/** A signature, consisting of a public key and a signature. */
@Value
public static class Signature {
diff --git a/src/main/java/org/stellar/sdk/Sep45Challenge.java b/src/main/java/org/stellar/sdk/Sep45Challenge.java
index 793e0d73d..0dedf2cf1 100644
--- a/src/main/java/org/stellar/sdk/Sep45Challenge.java
+++ b/src/main/java/org/stellar/sdk/Sep45Challenge.java
@@ -6,8 +6,10 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
+import java.util.EnumSet;
import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Set;
import lombok.Builder;
import lombok.NonNull;
import lombok.Value;
@@ -49,6 +51,17 @@ public class Sep45Challenge {
public static final String NULL_ACCOUNT =
"GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
+ // Credential types accepted in SEP-45 challenge entries. CAP-71 (protocol 27): simulation may
+ // return SOROBAN_CREDENTIALS_ADDRESS_V2 entries in addition to the legacy
+ // SOROBAN_CREDENTIALS_ADDRESS. Delegated credentials
+ // (SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) are rejected until the ecosystem defines how
+ // they interact with SEP-45.
+ private static final Set ALLOWED_CREDENTIAL_TYPES =
+ Collections.unmodifiableSet(
+ EnumSet.of(
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2));
+
private Sep45Challenge() {
// no instance
}
@@ -230,15 +243,18 @@ public static SorobanAuthorizationEntries buildChallengeAuthorizationEntries(
"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);
- }
+ if (!ALLOWED_CREDENTIAL_TYPES.contains(entry.getCredentials().getDiscriminant())) {
+ throw new InvalidSep45ChallengeException(
+ "Unsupported SorobanCredentialsType: " + entry.getCredentials().getDiscriminant());
+ }
+
+ // Check if this entry needs to be signed by the server.
+ Address address =
+ Address.fromSCAddress(Auth.getAddressCredentials(entry.getCredentials()).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);
@@ -472,12 +488,12 @@ public static ChallengeAuthorizationEntries readChallengeAuthorizationEntries(
for (SorobanAuthorizationEntry entry : entries) {
SorobanCredentials credentials = entry.getCredentials();
- if (credentials.getDiscriminant() != SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) {
+ if (!ALLOWED_CREDENTIAL_TYPES.contains(credentials.getDiscriminant())) {
throw new InvalidSep45ChallengeException(
- "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type");
+ "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2 type");
}
- SorobanAddressCredentials addressCredentials = credentials.getAddress();
+ SorobanAddressCredentials addressCredentials = Auth.getAddressCredentials(credentials);
Address credentialAddress = Address.fromSCAddress(addressCredentials.getAddress());
String encodedAddress = credentialAddress.getEncodedAddress();
diff --git a/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java b/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java
index 9c3c11b63..a20bbb6b9 100644
--- a/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java
+++ b/src/main/java/org/stellar/sdk/contract/AssembledTransaction.java
@@ -17,6 +17,7 @@
import lombok.Value;
import org.jetbrains.annotations.Nullable;
import org.stellar.sdk.*;
+import org.stellar.sdk.Auth;
import org.stellar.sdk.TimeBounds;
import org.stellar.sdk.Transaction;
import org.stellar.sdk.contract.exception.*;
@@ -149,6 +150,10 @@ public T signAndSubmit(@Nullable KeyPair transactionSigner, boolean force) {
/**
* Signs the transaction.
*
+ * Entries whose credential address is a contract ({@code C...}) are excluded from the
+ * missing-signature check: contract-account authorization is evaluated on-chain by the account's
+ * {@code __check_auth} and cannot be verified client-side.
+ *
* @param transactionSigner the keypair to sign the transaction with, or null to use
* the signer provided in the constructor
* @param force whether to sign and submit even if the transaction is a read call
@@ -215,6 +220,13 @@ public AssembledTransaction signAuthEntries(KeyPair authEntriesSigner) {
/**
* Signs the transaction's authorization entries.
*
+ * Only entries whose top-level credential address matches the signer are signed. Delegate
+ * nodes of {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entries (CAP-71-01) are not
+ * traversed — which delegates must sign is the account contract's policy. Fill them with {@link
+ * Auth#authorizeEntry(SorobanAuthorizationEntry, Auth.Signer, Long, Network, String)}, passing
+ * the delegate's address as {@code forAddress} and the same expiration ledger for every signer of
+ * one entry (the shared signature payload commits to it).
+ *
* @param authEntriesSigner the keypair to sign the authorization entries with
* @param validUntilLedgerSequence the ledger sequence number until which the authorization
* entries are valid, or null to set it to the current ledger sequence + 100
@@ -239,14 +251,13 @@ public AssembledTransaction signAuthEntries(
for (int i = 0; i < invokeHostFunctionOp.getAuth().size(); i++) {
SorobanAuthorizationEntry e = invokeHostFunctionOp.getAuth().get(i);
- if (SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT.equals(
- e.getCredentials().getDiscriminant())) {
+ SorobanAddressCredentials addressCredentials = Auth.getAddressCredentials(e.getCredentials());
+ if (addressCredentials == null) {
+ // source-account credentials don't need an explicit signature here, since the tx
+ // envelope is already signed by the source account
continue;
}
- if (e.getCredentials().getAddress() == null) {
- throw new IllegalStateException("Expected address in credentials");
- }
- if (!Address.fromSCAddress(e.getCredentials().getAddress().getAddress())
+ if (!Address.fromSCAddress(addressCredentials.getAddress())
.toString()
.equals(authEntriesSigner.getAccountId())) {
continue;
@@ -264,6 +275,12 @@ public AssembledTransaction signAuthEntries(
/**
* Get the addresses that need to sign the authorization entries.
*
+ * Only the top-level address credentials of each entry are considered. Delegate signers of
+ * {@code SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES} entries (CAP-71-01) are not reported: which
+ * delegates must sign is the account contract's policy, which the SDK cannot know — per CAP-71-01
+ * a listed delegate is only verified when the account's {@code __check_auth} consumes it, so a
+ * delegate left with a void signature may be perfectly valid.
+ *
* @param includeAlreadySigned whether to include addresses that have already signed the
* authorization entries
* @return The addresses that need to sign the authorization entries.
@@ -281,18 +298,17 @@ public Set needsNonInvokerSigningBy(boolean includeAlreadySigned) {
InvokeHostFunctionOperation invokeHostFunctionOp = (InvokeHostFunctionOperation) op;
return invokeHostFunctionOp.getAuth().stream()
+ .map(entry -> Auth.getAddressCredentials(entry.getCredentials()))
.filter(
- entry ->
- entry.getCredentials().getDiscriminant()
- == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)
- .filter(
- entry ->
- includeAlreadySigned
- || entry.getCredentials().getAddress().getSignature().getDiscriminant()
- == SCValType.SCV_VOID)
+ addressCredentials ->
+ // skip source-account credentials (no address payload), which are covered by
+ // the envelope signature on the source account
+ addressCredentials != null
+ && (includeAlreadySigned
+ || addressCredentials.getSignature().getDiscriminant()
+ == SCValType.SCV_VOID))
.map(
- entry ->
- Address.fromSCAddress(entry.getCredentials().getAddress().getAddress()).toString())
+ addressCredentials -> Address.fromSCAddress(addressCredentials.getAddress()).toString())
.collect(Collectors.toSet());
}
diff --git a/src/test/java/org/stellar/sdk/AuthTest.java b/src/test/java/org/stellar/sdk/AuthTest.java
index 793c13847..cdb321398 100644
--- a/src/test/java/org/stellar/sdk/AuthTest.java
+++ b/src/test/java/org/stellar/sdk/AuthTest.java
@@ -1,10 +1,14 @@
package org.stellar.sdk;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import org.junit.Test;
@@ -14,15 +18,18 @@
import org.stellar.sdk.xdr.HashIDPreimage;
import org.stellar.sdk.xdr.Int64;
import org.stellar.sdk.xdr.InvokeContractArgs;
+import org.stellar.sdk.xdr.SCAddress;
import org.stellar.sdk.xdr.SCVal;
import org.stellar.sdk.xdr.SCValType;
import org.stellar.sdk.xdr.SorobanAddressCredentials;
+import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates;
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.SorobanDelegateSignature;
import org.stellar.sdk.xdr.Uint32;
import org.stellar.sdk.xdr.XdrUnsignedInteger;
@@ -806,4 +813,828 @@ public void testSignAuthorizeEntryWithFunctionSignerNotEqualCredentialAddress()
assertEquals(expectedEntry, signedEntry);
assertNotSame(entry, signedEntry);
}
+
+ @Test
+ public void testSignAuthorizeEntryWithAddressV2Credentials() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(invocation)
+ .build();
+
+ SorobanAuthorizationEntry signedEntry =
+ Auth.authorizeEntry(entry, signer, validUntilLedgerSeq, network);
+
+ // CAP-71-02: the V2 payload is the address-bound
+ // ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS preimage.
+ HashIDPreimage preimage =
+ HashIDPreimage.builder()
+ .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS)
+ .sorobanAuthorizationWithAddress(
+ HashIDPreimage.HashIDPreimageSorobanAuthorizationWithAddress.builder()
+ .networkID(new Hash(network.getNetworkId()))
+ .nonce(new Int64(123456789L))
+ .signatureExpirationLedger(
+ new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
+ .address(new Address(signer.getAccountId()).toSCAddress())
+ .invocation(invocation)
+ .build())
+ .build();
+ byte[] signature = signer.sign(Util.hash(preimage.toXdrByteArray()));
+
+ SorobanCredentials expectedCredentials =
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)
+ .addressV2(
+ SorobanAddressCredentials.builder()
+ .address(new Address(signer.getAccountId()).toSCAddress())
+ .nonce(new Int64(123456789L))
+ .signatureExpirationLedger(
+ new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
+ .signature(buildSignatureScVal(signer, signature))
+ .build())
+ .build();
+ SorobanAuthorizationEntry expectedEntry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(expectedCredentials)
+ .rootInvocation(invocation)
+ .build();
+ assertEquals(expectedEntry, signedEntry);
+ assertNotSame(entry, signedEntry);
+ }
+
+ @Test
+ public void testBuildAuthorizationEntryPreimageWithAddressCredentials() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressCredentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(invocation)
+ .build();
+
+ HashIDPreimage preimage =
+ Auth.buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, network);
+ assertEquals(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION, preimage.getDiscriminant());
+ assertEquals(new Int64(123456789L), preimage.getSorobanAuthorization().getNonce());
+ assertEquals(invocation, preimage.getSorobanAuthorization().getInvocation());
+ assertEquals(
+ validUntilLedgerSeq,
+ preimage
+ .getSorobanAuthorization()
+ .getSignatureExpirationLedger()
+ .getUint32()
+ .getNumber()
+ .longValue());
+ }
+
+ @Test
+ public void testBuildAuthorizationEntryPreimageWithAddressV2Credentials() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(invocation)
+ .build();
+
+ HashIDPreimage preimage =
+ Auth.buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, network);
+ assertEquals(
+ EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, preimage.getDiscriminant());
+ assertEquals(
+ new Address(signer.getAccountId()).toSCAddress(),
+ preimage.getSorobanAuthorizationWithAddress().getAddress());
+ assertEquals(new Int64(123456789L), preimage.getSorobanAuthorizationWithAddress().getNonce());
+ assertEquals(invocation, preimage.getSorobanAuthorizationWithAddress().getInvocation());
+ assertEquals(
+ validUntilLedgerSeq,
+ preimage
+ .getSorobanAuthorizationWithAddress()
+ .getSignatureExpirationLedger()
+ .getUint32()
+ .getNumber()
+ .longValue());
+ }
+
+ @Test
+ public void testBuildAuthorizationEntryPreimageWithSourceAccountCredentialsThrows() {
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT)
+ .build())
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.buildAuthorizationEntryPreimage(entry, 654656L, Network.TESTNET);
+ fail();
+ } catch (IllegalArgumentException ignored) {
+ }
+ }
+
+ @Test
+ public void testAddressAndAddressV2ProduceDifferentPayloads() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+ SCAddress address = new Address(signer.getAccountId()).toSCAddress();
+
+ SorobanAuthorizationEntry legacyEntry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(buildAddressCredentials(address, 0L))
+ .rootInvocation(invocation)
+ .build();
+ SorobanAuthorizationEntry v2Entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(buildAddressV2Credentials(address, 0L))
+ .rootInvocation(invocation)
+ .build();
+
+ byte[] legacyPayload =
+ Util.hash(
+ Auth.buildAuthorizationEntryPreimage(legacyEntry, validUntilLedgerSeq, network)
+ .toXdrByteArray());
+ byte[] v2Payload =
+ Util.hash(
+ Auth.buildAuthorizationEntryPreimage(v2Entry, validUntilLedgerSeq, network)
+ .toXdrByteArray());
+ assertFalse(Arrays.equals(legacyPayload, v2Payload));
+ }
+
+ @Test
+ public void testBuildAuthorizationEntryPreimageMatchesAuthorizeEntryPayload() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+
+ HashIDPreimage[] captured = new HashIDPreimage[1];
+ Auth.Signer entrySigner =
+ preimage -> {
+ captured[0] = preimage;
+ byte[] data;
+ try {
+ data = preimage.toXdrByteArray();
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Unable to convert preimage to bytes", e);
+ }
+ return new Auth.Signature(signer.getAccountId(), signer.sign(Util.hash(data)));
+ };
+
+ Auth.authorizeEntry(entry, entrySigner, validUntilLedgerSeq, network);
+ assertEquals(
+ Auth.buildAuthorizationEntryPreimage(entry, validUntilLedgerSeq, network), captured[0]);
+ }
+
+ @Test
+ public void testSignAuthorizeEntryWithDelegatesCredentialsSignsTopLevel() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = KeyPair.fromAccountId(StrKey.encodeEd25519PublicKey(new byte[32]));
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildWithDelegatesCredentials(
+ new Address(signer.getAccountId()).toSCAddress(),
+ 0L,
+ new SCAddress[] {new Address(delegate.getAccountId()).toSCAddress()}))
+ .rootInvocation(invocation)
+ .build();
+
+ SorobanAuthorizationEntry signedEntry =
+ Auth.authorizeEntry(entry, signer, validUntilLedgerSeq, network);
+
+ // The payload is bound to the *top-level* address via the WithAddress preimage.
+ HashIDPreimage preimage =
+ HashIDPreimage.builder()
+ .discriminant(EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS)
+ .sorobanAuthorizationWithAddress(
+ HashIDPreimage.HashIDPreimageSorobanAuthorizationWithAddress.builder()
+ .networkID(new Hash(network.getNetworkId()))
+ .nonce(new Int64(123456789L))
+ .signatureExpirationLedger(
+ new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
+ .address(new Address(signer.getAccountId()).toSCAddress())
+ .invocation(invocation)
+ .build())
+ .build();
+ byte[] signature = signer.sign(Util.hash(preimage.toXdrByteArray()));
+
+ SorobanAddressCredentialsWithDelegates withDelegates =
+ signedEntry.getCredentials().getAddressWithDelegates();
+ // top-level credentials carry the signature ...
+ assertEquals(
+ buildSignatureScVal(signer, signature),
+ withDelegates.getAddressCredentials().getSignature());
+ assertEquals(
+ validUntilLedgerSeq,
+ withDelegates
+ .getAddressCredentials()
+ .getSignatureExpirationLedger()
+ .getUint32()
+ .getNumber()
+ .longValue());
+ // ... while the delegate node remains untouched
+ assertEquals(1, withDelegates.getDelegates().length);
+ assertEquals(
+ SCValType.SCV_VOID, withDelegates.getDelegates()[0].getSignature().getDiscriminant());
+ }
+
+ @Test
+ public void testSignAuthorizeEntryWithDelegatesForAddressFillsDelegateNode() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildWithDelegatesCredentials(
+ new Address(signer.getAccountId()).toSCAddress(),
+ 0L,
+ new SCAddress[] {new Address(delegate.getAccountId()).toSCAddress()}))
+ .rootInvocation(invocation)
+ .build();
+
+ SorobanAuthorizationEntry signedEntry =
+ Auth.authorizeEntry(entry, delegate, validUntilLedgerSeq, network, delegate.getAccountId());
+
+ // CAP-71-01: the delegate signs the same payload, bound to the *top-level* address.
+ HashIDPreimage preimage =
+ Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network);
+ assertEquals(
+ new Address(signer.getAccountId()).toSCAddress(),
+ preimage.getSorobanAuthorizationWithAddress().getAddress());
+ byte[] payload = Util.hash(preimage.toXdrByteArray());
+
+ SorobanAddressCredentialsWithDelegates withDelegates =
+ signedEntry.getCredentials().getAddressWithDelegates();
+ // the top-level signature remains a void placeholder ...
+ assertEquals(
+ SCValType.SCV_VOID, withDelegates.getAddressCredentials().getSignature().getDiscriminant());
+ // ... and the delegate node carries a verifiable signature over the shared payload
+ SCVal delegateSignature = withDelegates.getDelegates()[0].getSignature();
+ SCVal sigStruct = Scv.fromVec(delegateSignature).iterator().next();
+ byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature")));
+ assertTrue(delegate.verify(payload, signatureBytes));
+ }
+
+ @Test
+ public void testSignAuthorizeEntryWithDelegatesForAddressFillsNestedDelegateNode()
+ throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ KeyPair nestedDelegate = deterministicKeyPair((byte) 2);
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+
+ SorobanDelegateSignature nestedNode =
+ SorobanDelegateSignature.builder()
+ .address(new Address(nestedDelegate.getAccountId()).toSCAddress())
+ .signature(Scv.toVoid())
+ .nestedDelegates(new SorobanDelegateSignature[0])
+ .build();
+ SorobanDelegateSignature delegateNode =
+ SorobanDelegateSignature.builder()
+ .address(new Address(delegate.getAccountId()).toSCAddress())
+ .signature(Scv.toVoid())
+ .nestedDelegates(new SorobanDelegateSignature[] {nestedNode})
+ .build();
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)
+ .addressWithDelegates(
+ SorobanAddressCredentialsWithDelegates.builder()
+ .addressCredentials(
+ SorobanAddressCredentials.builder()
+ .address(new Address(signer.getAccountId()).toSCAddress())
+ .nonce(new Int64(123456789L))
+ .signatureExpirationLedger(
+ new Uint32(new XdrUnsignedInteger(0L)))
+ .signature(Scv.toVoid())
+ .build())
+ .delegates(new SorobanDelegateSignature[] {delegateNode})
+ .build())
+ .build())
+ .rootInvocation(buildInvocation())
+ .build();
+
+ SorobanAuthorizationEntry signedEntry =
+ Auth.authorizeEntry(
+ entry, nestedDelegate, validUntilLedgerSeq, network, nestedDelegate.getAccountId());
+
+ byte[] payload =
+ Util.hash(
+ Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network)
+ .toXdrByteArray());
+
+ SorobanAddressCredentialsWithDelegates withDelegates =
+ signedEntry.getCredentials().getAddressWithDelegates();
+ assertEquals(
+ SCValType.SCV_VOID, withDelegates.getAddressCredentials().getSignature().getDiscriminant());
+ assertEquals(
+ SCValType.SCV_VOID, withDelegates.getDelegates()[0].getSignature().getDiscriminant());
+ SCVal nestedSignature = withDelegates.getDelegates()[0].getNestedDelegates()[0].getSignature();
+ SCVal sigStruct = Scv.fromVec(nestedSignature).iterator().next();
+ byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature")));
+ assertTrue(nestedDelegate.verify(payload, signatureBytes));
+ }
+
+ @Test
+ public void testSignAuthorizeEntryWithForAddressNoMatchThrows() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair other = deterministicKeyPair((byte) 1);
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.authorizeEntry(entry, other, 654656L, Network.TESTNET, other.getAccountId());
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("no credential node for address"));
+ }
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntryWrapsAddressV2Credentials() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ long validUntilLedgerSeq = 654656L;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(invocation)
+ .build();
+
+ SorobanAuthorizationEntry wrapped =
+ Auth.buildWithDelegatesEntry(
+ entry,
+ validUntilLedgerSeq,
+ Collections.singletonList(
+ new Auth.DelegateSignature(delegate.getAccountId(), null, null)),
+ null);
+
+ assertEquals(
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES,
+ wrapped.getCredentials().getDiscriminant());
+ assertEquals(invocation, wrapped.getRootInvocation());
+
+ SorobanAddressCredentialsWithDelegates withDelegates =
+ wrapped.getCredentials().getAddressWithDelegates();
+ assertEquals(
+ new Address(signer.getAccountId()).toSCAddress(),
+ withDelegates.getAddressCredentials().getAddress());
+ assertEquals(new Int64(123456789L), withDelegates.getAddressCredentials().getNonce());
+ assertEquals(
+ validUntilLedgerSeq,
+ withDelegates
+ .getAddressCredentials()
+ .getSignatureExpirationLedger()
+ .getUint32()
+ .getNumber()
+ .longValue());
+ // the top-level signature defaults to a void placeholder, valid for accounts that
+ // authorize purely via delegated signers
+ assertEquals(
+ SCValType.SCV_VOID, withDelegates.getAddressCredentials().getSignature().getDiscriminant());
+ assertEquals(1, withDelegates.getDelegates().length);
+ assertEquals(
+ new Address(delegate.getAccountId()).toSCAddress(),
+ withDelegates.getDelegates()[0].getAddress());
+ assertEquals(
+ SCValType.SCV_VOID, withDelegates.getDelegates()[0].getSignature().getDiscriminant());
+ assertEquals(0, withDelegates.getDelegates()[0].getNestedDelegates().length);
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntrySortsDelegatesByAddress() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ String contractId = "CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK";
+ byte[] lowKey = new byte[32];
+ byte[] highKey = new byte[32];
+ highKey[0] = (byte) 0xff;
+ String gLow = StrKey.encodeEd25519PublicKey(lowKey);
+ String gHigh = StrKey.encodeEd25519PublicKey(highKey);
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+
+ // accounts (discriminant 0) sort before contracts (discriminant 1), and accounts sort by
+ // their ed25519 bytes
+ SorobanAuthorizationEntry wrapped =
+ Auth.buildWithDelegatesEntry(
+ entry,
+ 654656L,
+ Arrays.asList(
+ new Auth.DelegateSignature(contractId, null, null),
+ new Auth.DelegateSignature(
+ gHigh,
+ null,
+ Arrays.asList(
+ new Auth.DelegateSignature(gHigh, null, null),
+ new Auth.DelegateSignature(gLow, null, null))),
+ new Auth.DelegateSignature(gLow, null, null)),
+ null);
+
+ SorobanDelegateSignature[] delegates =
+ wrapped.getCredentials().getAddressWithDelegates().getDelegates();
+ assertEquals(3, delegates.length);
+ assertEquals(new Address(gLow).toSCAddress(), delegates[0].getAddress());
+ assertEquals(new Address(gHigh).toSCAddress(), delegates[1].getAddress());
+ assertEquals(new Address(contractId).toSCAddress(), delegates[2].getAddress());
+ // nested levels are sorted too
+ SorobanDelegateSignature[] nested = delegates[1].getNestedDelegates();
+ assertEquals(2, nested.length);
+ assertEquals(new Address(gLow).toSCAddress(), nested[0].getAddress());
+ assertEquals(new Address(gHigh).toSCAddress(), nested[1].getAddress());
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntryWithDuplicateDelegateAddressThrows() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.buildWithDelegatesEntry(
+ entry,
+ 654656L,
+ Arrays.asList(
+ new Auth.DelegateSignature(delegate.getAccountId(), null, null),
+ new Auth.DelegateSignature(delegate.getAccountId(), null, null)),
+ null);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("duplicate delegate address"));
+ }
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntryRejectsWithDelegatesCredentials() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildWithDelegatesCredentials(
+ new Address(signer.getAccountId()).toSCAddress(), 0L, new SCAddress[0]))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.buildWithDelegatesEntry(entry, 654656L, Collections.emptyList(), null);
+ fail();
+ } catch (IllegalArgumentException ignored) {
+ }
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntryComposesWithAuthorizeEntry() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+
+ SorobanAuthorizationEntry simulatedEntry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+
+ SorobanAuthorizationEntry wrapped =
+ Auth.buildWithDelegatesEntry(
+ simulatedEntry,
+ validUntilLedgerSeq,
+ Collections.singletonList(
+ new Auth.DelegateSignature(delegate.getAccountId(), null, null)),
+ null);
+ SorobanAuthorizationEntry signedEntry =
+ Auth.authorizeEntry(
+ wrapped, delegate, validUntilLedgerSeq, network, delegate.getAccountId());
+
+ byte[] payload =
+ Util.hash(
+ Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network)
+ .toXdrByteArray());
+ SCVal delegateSignature =
+ signedEntry.getCredentials().getAddressWithDelegates().getDelegates()[0].getSignature();
+ SCVal sigStruct = Scv.fromVec(delegateSignature).iterator().next();
+ byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature")));
+ assertTrue(delegate.verify(payload, signatureBytes));
+ }
+
+ @Test
+ public void testWithDelegatesCredentialsXdrRoundTrip() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ KeyPair nestedDelegate = deterministicKeyPair((byte) 2);
+
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)
+ .addressWithDelegates(
+ SorobanAddressCredentialsWithDelegates.builder()
+ .addressCredentials(
+ SorobanAddressCredentials.builder()
+ .address(new Address(signer.getAccountId()).toSCAddress())
+ .nonce(new Int64(123456789L))
+ .signatureExpirationLedger(
+ new Uint32(new XdrUnsignedInteger(654656L)))
+ .signature(Scv.toVoid())
+ .build())
+ .delegates(
+ new SorobanDelegateSignature[] {
+ SorobanDelegateSignature.builder()
+ .address(new Address(delegate.getAccountId()).toSCAddress())
+ .signature(Scv.toVoid())
+ .nestedDelegates(
+ new SorobanDelegateSignature[] {
+ SorobanDelegateSignature.builder()
+ .address(
+ new Address(nestedDelegate.getAccountId())
+ .toSCAddress())
+ .signature(Scv.toVoid())
+ .nestedDelegates(new SorobanDelegateSignature[0])
+ .build()
+ })
+ .build()
+ })
+ .build())
+ .build())
+ .rootInvocation(buildInvocation())
+ .build();
+
+ assertEquals(entry, SorobanAuthorizationEntry.fromXdrBase64(entry.toXdrBase64()));
+ }
+
+ @Test
+ public void testGetAddressCredentials() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ SCAddress address = new Address(signer.getAccountId()).toSCAddress();
+
+ assertEquals(
+ address, Auth.getAddressCredentials(buildAddressCredentials(address, 0L)).getAddress());
+ assertEquals(
+ address, Auth.getAddressCredentials(buildAddressV2Credentials(address, 0L)).getAddress());
+ assertEquals(
+ address,
+ Auth.getAddressCredentials(buildWithDelegatesCredentials(address, 0L, new SCAddress[0]))
+ .getAddress());
+ assertNull(
+ Auth.getAddressCredentials(
+ SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_SOURCE_ACCOUNT)
+ .build()));
+ }
+
+ @Test
+ public void testAuthorizeEntryMatchesJsSdkVectors() throws IOException {
+ // Vectors generated with @stellar/stellar-sdk v16.0.0-rc.1 (CAP-71): the exact same inputs
+ // (signer, nonce 123456789, expiration ledger 654656, TESTNET, "increment" invocation) must
+ // produce byte-identical signed entries in both SDKs.
+ String addressSignedVector =
+ "AAAAAQAAAAAAAAAAWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAAB1vNFQAJ/UAAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABACOR9aTfdmCyXluY4UFurnM36CX91IL7qogZxFJ+fsFzKssUpDyHB0kxfPZVq9plTLfB14HoboD5tB71CgdWuBgAAAAAAAAABxYsr+8TwVOcyT2vyDK0+Am5Bu60abSDD19SRje0WVBEAAAAJaW5jcmVtZW50AAAAAAAAAAAAAAA=";
+ String addressV2SignedVector =
+ "AAAAAgAAAAAAAAAAWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAAB1vNFQAJ/UAAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABAUT1dqOx7TCk7ZHTruqLGInEw9QkSI7XrxlE1fVwbiU+viyQtrvIAP6vPPHOexFGnqmoPjkMwdeK0kWWUt90xBgAAAAAAAAABxYsr+8TwVOcyT2vyDK0+Am5Bu60abSDD19SRje0WVBEAAAAJaW5jcmVtZW50AAAAAAAAAAAAAAA=";
+ String delegatesSignedVector =
+ "AAAAAwAAAAAAAAAAWLfEosjyl6qPPSRxKB/fzOyv5I5WYzE+wY4Spz7KmKEAAAAAB1vNFQAJ/UAAAAABAAAAAQAAAAAAAAAAiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAQAAAAAQAAAAEAAAARAAAAAQAAAAIAAAAPAAAACnB1YmxpY19rZXkAAAAAAA0AAAAgiojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1wAAAAPAAAACXNpZ25hdHVyZQAAAAAAAA0AAABAvI04jRWOZ3J6mWQPMcXE0s26eR9FrpZ9iTcqBic4trJ3FcViAU4qpRoue3Ew2ViqvYKbzHwHtNbdFLYR5MXfCwAAAAAAAAAAAAAAAcWLK/vE8FTnMk9r8gytPgJuQbutGm0gw9fUkY3tFlQRAAAACWluY3JlbWVudAAAAAAAAAAAAAAA";
+
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ KeyPair delegate = deterministicKeyPair((byte) 1);
+ assertEquals(
+ "GCFIRY65OQE7DFP5KLNS2PF2LVZMUZYJX4OZIEQ36N2IQANUB5XVYOJR", delegate.getAccountId());
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SCAddress signerAddress = new Address(signer.getAccountId()).toSCAddress();
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry legacyEntry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(buildAddressCredentials(signerAddress, 0L))
+ .rootInvocation(invocation)
+ .build();
+ assertEquals(
+ addressSignedVector,
+ Auth.authorizeEntry(legacyEntry, signer, validUntilLedgerSeq, network).toXdrBase64());
+
+ SorobanAuthorizationEntry v2Entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(buildAddressV2Credentials(signerAddress, 0L))
+ .rootInvocation(invocation)
+ .build();
+ assertEquals(
+ addressV2SignedVector,
+ Auth.authorizeEntry(v2Entry, signer, validUntilLedgerSeq, network).toXdrBase64());
+
+ SorobanAuthorizationEntry wrapped =
+ Auth.buildWithDelegatesEntry(
+ v2Entry,
+ validUntilLedgerSeq,
+ Collections.singletonList(
+ new Auth.DelegateSignature(delegate.getAccountId(), null, null)),
+ null);
+ assertEquals(
+ delegatesSignedVector,
+ Auth.authorizeEntry(
+ wrapped, delegate, validUntilLedgerSeq, network, delegate.getAccountId())
+ .toXdrBase64());
+ }
+
+ @Test
+ public void authorizeInvocationWithAddressV2CredentialsType() throws IOException {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ long validUntilLedgerSeq = 654656L;
+ Network network = Network.TESTNET;
+ SorobanAuthorizedInvocation invocation = buildInvocation();
+
+ SorobanAuthorizationEntry signedEntry =
+ Auth.authorizeInvocation(
+ signer,
+ validUntilLedgerSeq,
+ invocation,
+ network,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2);
+
+ assertEquals(signedEntry.getRootInvocation(), invocation);
+ assertEquals(
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2,
+ signedEntry.getCredentials().getDiscriminant());
+ SorobanAddressCredentials addressCredentials = signedEntry.getCredentials().getAddressV2();
+ assertEquals(new Address(signer.getAccountId()).toSCAddress(), addressCredentials.getAddress());
+ assertEquals(SCValType.SCV_VEC, addressCredentials.getSignature().getDiscriminant());
+
+ // The signature verifies against the address-bound (CAP-71-02) payload.
+ HashIDPreimage preimage =
+ Auth.buildAuthorizationEntryPreimage(signedEntry, validUntilLedgerSeq, network);
+ assertEquals(
+ EnvelopeType.ENVELOPE_TYPE_SOROBAN_AUTHORIZATION_WITH_ADDRESS, preimage.getDiscriminant());
+ byte[] payload = Util.hash(preimage.toXdrByteArray());
+ SCVal sigStruct = Scv.fromVec(addressCredentials.getSignature()).iterator().next();
+ byte[] signatureBytes = Scv.fromBytes(Scv.fromMap(sigStruct).get(Scv.toSymbol("signature")));
+ assertTrue(signer.verify(payload, signatureBytes));
+ }
+
+ @Test
+ public void authorizeInvocationWithUnsupportedCredentialsTypeThrows() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ try {
+ Auth.authorizeInvocation(
+ signer,
+ 654656L,
+ buildInvocation(),
+ Network.TESTNET,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("credentialsType must be"));
+ }
+ }
+
+ private static KeyPair deterministicKeyPair(byte fill) {
+ byte[] seed = new byte[32];
+ Arrays.fill(seed, fill);
+ return KeyPair.fromSecretSeed(seed);
+ }
+
+ private static SorobanAuthorizedInvocation buildInvocation() {
+ String contractId = "CDCYWK73YTYFJZZSJ5V7EDFNHYBG4QN3VUNG2IGD27KJDDPNCZKBCBXK";
+ return SorobanAuthorizedInvocation.builder()
+ .function(
+ SorobanAuthorizedFunction.builder()
+ .discriminant(
+ SorobanAuthorizedFunctionType.SOROBAN_AUTHORIZED_FUNCTION_TYPE_CONTRACT_FN)
+ .contractFn(
+ InvokeContractArgs.builder()
+ .contractAddress(new Address(contractId).toSCAddress())
+ .functionName(Scv.toSymbol("increment").getSym())
+ .args(new SCVal[0])
+ .build())
+ .build())
+ .subInvocations(new SorobanAuthorizedInvocation[0])
+ .build();
+ }
+
+ private static SorobanAddressCredentials buildInnerAddressCredentials(
+ SCAddress address, long signatureExpirationLedger) {
+ return SorobanAddressCredentials.builder()
+ .address(address)
+ .nonce(new Int64(123456789L))
+ .signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(signatureExpirationLedger)))
+ .signature(Scv.toVoid())
+ .build();
+ }
+
+ private static SorobanCredentials buildAddressCredentials(
+ SCAddress address, long signatureExpirationLedger) {
+ return SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)
+ .address(buildInnerAddressCredentials(address, signatureExpirationLedger))
+ .build();
+ }
+
+ private static SorobanCredentials buildAddressV2Credentials(
+ SCAddress address, long signatureExpirationLedger) {
+ return SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2)
+ .addressV2(buildInnerAddressCredentials(address, signatureExpirationLedger))
+ .build();
+ }
+
+ private static SorobanCredentials buildWithDelegatesCredentials(
+ SCAddress address, long signatureExpirationLedger, SCAddress[] delegateAddresses) {
+ SorobanDelegateSignature[] delegates = new SorobanDelegateSignature[delegateAddresses.length];
+ for (int i = 0; i < delegateAddresses.length; i++) {
+ delegates[i] =
+ SorobanDelegateSignature.builder()
+ .address(delegateAddresses[i])
+ .signature(Scv.toVoid())
+ .nestedDelegates(new SorobanDelegateSignature[0])
+ .build();
+ }
+ return SorobanCredentials.builder()
+ .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES)
+ .addressWithDelegates(
+ SorobanAddressCredentialsWithDelegates.builder()
+ .addressCredentials(
+ buildInnerAddressCredentials(address, signatureExpirationLedger))
+ .delegates(delegates)
+ .build())
+ .build();
+ }
+
+ private static SCVal buildSignatureScVal(KeyPair signer, byte[] signature) {
+ return Scv.toVec(
+ Collections.singleton(
+ Scv.toMap(
+ new LinkedHashMap() {
+ {
+ put(Scv.toSymbol("public_key"), Scv.toBytes(signer.getPublicKey()));
+ put(Scv.toSymbol("signature"), Scv.toBytes(signature));
+ }
+ })));
+ }
}
diff --git a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
index 6d3072ec4..2019f8f83 100644
--- a/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
+++ b/src/test/java/org/stellar/sdk/Sep45ChallengeTest.java
@@ -24,7 +24,9 @@
import org.stellar.sdk.xdr.InvokeContractArgs;
import org.stellar.sdk.xdr.SCSymbol;
import org.stellar.sdk.xdr.SCVal;
+import org.stellar.sdk.xdr.SCValType;
import org.stellar.sdk.xdr.SorobanAddressCredentials;
+import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates;
import org.stellar.sdk.xdr.SorobanAuthorizationEntries;
import org.stellar.sdk.xdr.SorobanAuthorizationEntry;
import org.stellar.sdk.xdr.SorobanAuthorizedFunction;
@@ -32,6 +34,7 @@
import org.stellar.sdk.xdr.SorobanAuthorizedInvocation;
import org.stellar.sdk.xdr.SorobanCredentials;
import org.stellar.sdk.xdr.SorobanCredentialsType;
+import org.stellar.sdk.xdr.SorobanDelegateSignature;
import org.stellar.sdk.xdr.Uint32;
import org.stellar.sdk.xdr.XdrString;
import org.stellar.sdk.xdr.XdrUnsignedInteger;
@@ -99,6 +102,14 @@ private SorobanAuthorizedInvocation buildValidInvocation(boolean includeClientDo
*/
private SorobanAuthorizationEntry buildAuthorizationEntry(
String address, SorobanAuthorizedInvocation invocation) {
+ return buildAuthorizationEntry(
+ address, invocation, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS);
+ }
+
+ private SorobanAuthorizationEntry buildAuthorizationEntry(
+ String address,
+ SorobanAuthorizedInvocation invocation,
+ SorobanCredentialsType credentialsType) {
Address addr = new Address(address);
SorobanAddressCredentials addressCredentials =
@@ -109,14 +120,25 @@ private SorobanAuthorizationEntry buildAuthorizationEntry(
.signature(Scv.toVoid())
.build();
- SorobanCredentials credentials =
- SorobanCredentials.builder()
- .discriminant(SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS)
- .address(addressCredentials)
- .build();
+ SorobanCredentials.SorobanCredentialsBuilder credentialsBuilder =
+ SorobanCredentials.builder().discriminant(credentialsType);
+ if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS) {
+ credentialsBuilder.address(addressCredentials);
+ } else if (credentialsType == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2) {
+ credentialsBuilder.addressV2(addressCredentials);
+ } else if (credentialsType
+ == SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES) {
+ credentialsBuilder.addressWithDelegates(
+ SorobanAddressCredentialsWithDelegates.builder()
+ .addressCredentials(addressCredentials)
+ .delegates(new SorobanDelegateSignature[0])
+ .build());
+ } else {
+ throw new IllegalArgumentException("Unsupported credentials type: " + credentialsType);
+ }
return SorobanAuthorizationEntry.builder()
- .credentials(credentials)
+ .credentials(credentialsBuilder.build())
.rootInvocation(invocation)
.build();
}
@@ -800,7 +822,62 @@ public void testReadChallengeUnsupportedCredentialsType() {
} catch (InvalidSep45ChallengeException e) {
assertTrue(
e.getMessage()
- .contains("All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS type"));
+ .contains(
+ "All authorization entries must have SOROBAN_CREDENTIALS_ADDRESS or SOROBAN_CREDENTIALS_ADDRESS_V2 type"));
+ }
+ }
+
+ @Test
+ public void testReadChallengeSuccessWithAddressV2Credentials() {
+ // CAP-71 (protocol 27): SOROBAN_CREDENTIALS_ADDRESS_V2 entries are accepted in addition to
+ // the legacy SOROBAN_CREDENTIALS_ADDRESS.
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(false);
+
+ List entries = new ArrayList<>();
+ entries.add(
+ buildAuthorizationEntry(
+ CLIENT_CONTRACT, invocation, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2));
+ entries.add(
+ buildAuthorizationEntry(
+ SERVER_ACCOUNT, invocation, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2));
+
+ 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(NONCE, result.getNonce());
+ assertEquals(2, result.getAuthorizationEntries().size());
+ }
+
+ @Test
+ public void testReadChallengeRejectsWithDelegatesCredentials() {
+ // Delegated credentials (CAP-71-01) are rejected until the ecosystem defines how they
+ // interact with SEP-45.
+ SorobanAuthorizedInvocation invocation = buildValidInvocation(false);
+
+ List entries = new ArrayList<>();
+ entries.add(
+ buildAuthorizationEntry(
+ CLIENT_CONTRACT,
+ invocation,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES));
+ 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 or SOROBAN_CREDENTIALS_ADDRESS_V2 type"));
}
}
@@ -917,6 +994,95 @@ public void testBuildChallengeAuthorizationEntriesWithoutClientDomain() throws I
server.close();
}
+ @Test
+ public void testBuildChallengeAuthorizationEntriesSignsAddressV2Entries() throws IOException {
+ // CAP-71 (protocol 27): a P27 RPC may return SOROBAN_CREDENTIALS_ADDRESS_V2 entries from
+ // simulation; the server entry must still get signed.
+ KeyPair serverSigner = KeyPair.random();
+ String serverAccountId = serverSigner.getAccountId();
+
+ String mockResponse =
+ buildMockSimulateResponse(
+ serverAccountId, false, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2);
+
+ 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);
+
+ // The server's V2 entry is signed, the client entry remains an unsigned placeholder.
+ boolean serverEntryChecked = false;
+ for (SorobanAuthorizationEntry entry : result.getSorobanAuthorizationEntries()) {
+ assertEquals(
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_V2,
+ entry.getCredentials().getDiscriminant());
+ SorobanAddressCredentials addressCredentials =
+ Auth.getAddressCredentials(entry.getCredentials());
+ String entryAddress = Address.fromSCAddress(addressCredentials.getAddress()).toString();
+ if (entryAddress.equals(serverAccountId)) {
+ assertEquals(SCValType.SCV_VEC, addressCredentials.getSignature().getDiscriminant());
+ serverEntryChecked = true;
+ } else {
+ assertEquals(SCValType.SCV_VOID, addressCredentials.getSignature().getDiscriminant());
+ }
+ }
+ assertTrue(serverEntryChecked);
+
+ mockWebServer.close();
+ server.close();
+ }
+
+ @Test
+ public void testBuildChallengeAuthorizationEntriesRejectsUnsupportedCredentialsType()
+ throws IOException {
+ // Delegated credentials are not allowed in SEP-45 challenges; challenge building must fail
+ // fast instead of emitting an unsigned entry.
+ KeyPair serverSigner = KeyPair.random();
+ String serverAccountId = serverSigner.getAccountId();
+
+ String mockResponse =
+ buildMockSimulateResponse(
+ serverAccountId,
+ false,
+ SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS_WITH_DELEGATES);
+
+ 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("Unsupported SorobanCredentialsType"));
+ }
+ }
+ }
+
@Test
public void testBuildChallengeAuthorizationEntriesClientDomainWithoutAccount()
throws IOException {
@@ -1208,6 +1374,12 @@ private String buildEntriesWithCustomArgs(LinkedHashMap argsMap) {
* @return JSON string of the mock response
*/
private String buildMockSimulateResponse(String serverAccountId, boolean includeClientDomain) {
+ return buildMockSimulateResponse(
+ serverAccountId, includeClientDomain, SorobanCredentialsType.SOROBAN_CREDENTIALS_ADDRESS);
+ }
+
+ private String buildMockSimulateResponse(
+ String serverAccountId, boolean includeClientDomain, SorobanCredentialsType credentialsType) {
// Build mock authorization entries for the response
List mockEntries = new ArrayList<>();
@@ -1244,11 +1416,11 @@ private String buildMockSimulateResponse(String serverAccountId, boolean include
.build();
// Client entry
- mockEntries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation));
+ mockEntries.add(buildAuthorizationEntry(CLIENT_CONTRACT, invocation, credentialsType));
// Server entry
- mockEntries.add(buildAuthorizationEntry(serverAccountId, invocation));
+ mockEntries.add(buildAuthorizationEntry(serverAccountId, invocation, credentialsType));
if (includeClientDomain) {
- mockEntries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation));
+ mockEntries.add(buildAuthorizationEntry(CLIENT_DOMAIN_ACCOUNT, invocation, credentialsType));
}
// Convert entries to XDR base64 strings