Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
- chore: upgrade generated XDR definitions to Protocol 30.
- fix: `StrKey.decodeClaimableBalance` and `StrKey.isValidClaimableBalance` now reject a claimable balance strkey (`B...`) whose type byte is not 0, as SEP-23 requires; `CLAIMABLE_BALANCE_ID_TYPE_V0` is the only type. Such a key used to be accepted, so `new Address(...)` took it and the error only surfaced in `Address.toSCAddress`. `new Address(...)` now throws `IllegalArgumentException` up front.
- refactor: deprecate `GetEventsResponse.EventInfo.inSuccessfulContractCall`. Stellar RPC no longer returns it from `getEvents` ([stellar-rpc#1050](https://github.com/stellar/stellar-rpc/pull/1050)), so it is `null` with newer servers. It will be removed in the next major release.
- feat: add CAP-84 muxed contract address (`W...`) support.
- New `StrKey.encodeMuxedContract`, `StrKey.decodeMuxedContract`, and `StrKey.isValidMuxedContract`. The raw form is the 32-byte contract ID followed by the 8-byte big-endian ID, as specified by SEP-23.
- `Address` accepts `W...` addresses and converts `SC_ADDRESS_TYPE_MUXED_CONTRACT` to and from `SCAddress` and `SCVal`, with the new `Address.AddressType.MUXED_CONTRACT` and `Address.fromMuxedContract(byte[])`. Contract return values and events that hold a muxed contract address no longer fail to decode.
- Map keys holding muxed contract addresses are now sorted correctly when building an `SCV_MAP`.
- A muxed contract address is only valid where a muxed account (`M...`) is, such as the `to` parameter of the SAC `transfer`. The SDK now applies the Soroban host's rules up front instead of building transactions that fail at simulation or submission:
- Authorization addresses must be an account (`G...`) or a contract (`C...`), as the host requires. `Auth.authorizeInvocation`, the `forAddress` of `Auth.authorizeEntry`, `Auth.DelegateSignature`, and the credentials of an entry passed to `Auth.authorizeEntry`, `Auth.buildAuthorizationEntryPreimage`, or `Auth.buildWithDelegatesEntry` reject any other address type, including muxed accounts (`M...`), which were accepted before.
- `SorobanServer.getContractData` requires a contract address (`C...`) and rejects a `key` that contains a muxed address (`M...` or `W...`) anywhere, since the host never stores such keys.
- `TransactionBuilder.buildRestoreAssetBalanceEntryTransaction` requires `balanceOwner` to be a contract address (`C...`). Only contracts hold SAC balances in contract data; an account's balance lives in its trustline.

## 5.0.0

Expand Down
46 changes: 43 additions & 3 deletions src/main/java/org/stellar/sdk/Address.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package org.stellar.sdk;

import java.io.IOException;
import java.math.BigInteger;
import java.nio.ByteBuffer;
import java.util.Arrays;
import lombok.EqualsAndHashCode;
import org.stellar.sdk.xdr.ClaimableBalanceID;
import org.stellar.sdk.xdr.ClaimableBalanceIDType;
import org.stellar.sdk.xdr.ContractID;
import org.stellar.sdk.xdr.Hash;
import org.stellar.sdk.xdr.MuxedContract;
import org.stellar.sdk.xdr.MuxedEd25519Account;
import org.stellar.sdk.xdr.PoolID;
import org.stellar.sdk.xdr.SCAddress;
Expand All @@ -18,7 +21,7 @@

/**
* Represents a single address in the Stellar network. An address can represent an account,
* contract, muxed account, claimable balance, or liquidity pool.
* contract, muxed account, claimable balance, liquidity pool, or muxed contract.
*/
@EqualsAndHashCode
public class Address {
Expand All @@ -29,7 +32,8 @@ public class Address {

/**
* Creates a new {@link Address} from a Stellar public key (G...), contract ID (C...), med25519
* public key (M...), liquidity pool ID (L...), or claimable balance ID (B...).
* public key (M...), liquidity pool ID (L...), claimable balance ID (B...), or muxed contract
* address (W...).
*
* @param address the StrKey encoded format of Stellar address.
*/
Expand All @@ -49,6 +53,9 @@ public Address(String address) {
} else if (StrKey.isValidLiquidityPool(address)) {
this.type = AddressType.LIQUIDITY_POOL;
this.key = StrKey.decodeLiquidityPool(address);
} else if (StrKey.isValidMuxedContract(address)) {
this.type = AddressType.MUXED_CONTRACT;
this.key = StrKey.decodeMuxedContract(address);
} else {
throw new IllegalArgumentException("Unsupported address type");
}
Expand Down Expand Up @@ -104,6 +111,17 @@ public static Address fromLiquidityPool(byte[] liquidityPoolId) {
return new Address(StrKey.encodeLiquidityPool(liquidityPoolId));
}

/**
* Creates a new {@link Address} from a Stellar muxed contract address.
*
* @param muxedContract the byte array of the Stellar muxed contract address (W...), the 32-byte
* contract ID followed by the 8-byte big-endian ID.
* @return a new {@link Address} object from the given Stellar muxed contract address.
*/
public static Address fromMuxedContract(byte[] muxedContract) {
return new Address(StrKey.encodeMuxedContract(muxedContract));
}

/**
* Creates a new {@link Address} from a {@link SCAddress} XDR object.
*
Expand Down Expand Up @@ -135,6 +153,14 @@ public static Address fromSCAddress(SCAddress scAddress) {
return fromClaimableBalance(withZeroPrefix);
case SC_ADDRESS_TYPE_LIQUIDITY_POOL:
return fromLiquidityPool(scAddress.getLiquidityPoolId().getPoolID().getHash());
case SC_ADDRESS_TYPE_MUXED_CONTRACT:
MuxedContract muxedContract = scAddress.getMuxedContract();
return fromMuxedContract(
ByteBuffer.allocate(40)
.put(muxedContract.getContractId().getContractID().getHash())
// See StrKey#toRawMuxedAccountStrKey for why longValue() is safe for a uint64.
.putLong(muxedContract.getId().getUint64().getNumber().longValue())
.array());
default:
throw new IllegalArgumentException("Unsupported address type");
}
Expand Down Expand Up @@ -206,6 +232,17 @@ public SCAddress toSCAddress() {
throw new IllegalArgumentException("Invalid liquidity pool ID", e);
}
break;
case MUXED_CONTRACT:
scAddress.setDiscriminant(org.stellar.sdk.xdr.SCAddressType.SC_ADDRESS_TYPE_MUXED_CONTRACT);
scAddress.setMuxedContract(
MuxedContract.builder()
.id(
new Uint64(
new XdrUnsignedHyperInteger(
new BigInteger(1, Arrays.copyOfRange(this.key, 32, 40)))))
.contractId(new ContractID(new Hash(Arrays.copyOfRange(this.key, 0, 32))))
.build());
break;
default:
throw new IllegalArgumentException("Unsupported address type");
}
Expand Down Expand Up @@ -260,6 +297,8 @@ public String getEncodedAddress() {
return StrKey.encodeClaimableBalance(this.key);
case LIQUIDITY_POOL:
return StrKey.encodeLiquidityPool(this.key);
case MUXED_CONTRACT:
return StrKey.encodeMuxedContract(this.key);
default:
throw new IllegalArgumentException("Unsupported address type");
}
Expand All @@ -276,6 +315,7 @@ public enum AddressType {
CONTRACT,
MUXED_ACCOUNT,
CLAIMABLE_BALANCE,
LIQUIDITY_POOL
LIQUIDITY_POOL,
MUXED_CONTRACT
}
}
41 changes: 36 additions & 5 deletions src/main/java/org/stellar/sdk/Auth.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.stellar.sdk.xdr.HashIDPreimage;
import org.stellar.sdk.xdr.Int64;
import org.stellar.sdk.xdr.SCAddress;
import org.stellar.sdk.xdr.SCAddressType;
import org.stellar.sdk.xdr.SCVal;
import org.stellar.sdk.xdr.SorobanAddressCredentials;
import org.stellar.sdk.xdr.SorobanAddressCredentialsWithDelegates;
Expand Down Expand Up @@ -352,7 +353,7 @@ public static SorobanAuthorizationEntry authorizeEntry(
if (forAddress == null) {
addressCredentials.setSignature(signatureScVal);
} else {
SCAddress forScAddress = new Address(forAddress).toSCAddress();
SCAddress forScAddress = toAuthScAddress(forAddress);
int filled = fillMatchingSignatureNodes(clone.getCredentials(), forScAddress, signatureScVal);
if (filled == 0) {
throw new IllegalArgumentException(
Expand Down Expand Up @@ -537,7 +538,7 @@ public static SorobanAuthorizationEntry authorizeInvocation(
long nonce = new SecureRandom().nextLong();
SorobanAddressCredentials addressCredentials =
SorobanAddressCredentials.builder()
.address(new Address(address).toSCAddress())
.address(toAuthScAddress(address))
.nonce(new Int64(nonce))
.signatureExpirationLedger(new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq)))
.signature(Scv.toVoid())
Expand Down Expand Up @@ -680,7 +681,8 @@ public static SCVal defaultAccountSignatureScVal(String accountId, byte[] signat
* @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
* non-address credentials, or if its credential address is not an account ({@code G...}) or
* contract ({@code C...})
*/
public static HashIDPreimage buildAuthorizationEntryPreimage(
SorobanAuthorizationEntry entry, long validUntilLedgerSeq, Network network) {
Expand All @@ -691,6 +693,7 @@ public static HashIDPreimage buildAuthorizationEntryPreimage(
"cannot build a signature payload for credentials type: "
+ credentials.getDiscriminant());
}
ensureAuthorizationScAddress(addressCredentials.getAddress());

Uint32 signatureExpirationLedger = new Uint32(new XdrUnsignedInteger(validUntilLedgerSeq));
switch (credentials.getDiscriminant()) {
Expand Down Expand Up @@ -760,7 +763,9 @@ public static HashIDPreimage buildAuthorizationEntryPreimage(
* 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
* ADDRESS_V2} credentials, if any delegates list contains a duplicate address, or if the
* entry's address or any delegate address is not an account ({@code G...}) or contract
* ({@code C...})
*/
public static SorobanAuthorizationEntry buildWithDelegatesEntry(
SorobanAuthorizationEntry entry,
Expand All @@ -775,6 +780,7 @@ public static SorobanAuthorizationEntry buildWithDelegatesEntry(
+ credentials.getDiscriminant());
}
SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials);
ensureAuthorizationScAddress(addressCredentials.getAddress());

return SorobanAuthorizationEntry.builder()
.credentials(
Expand Down Expand Up @@ -815,7 +821,7 @@ private static SorobanDelegateSignature[] buildDelegateNodes(
for (DelegateSignature delegate : delegates) {
SorobanDelegateSignature node =
SorobanDelegateSignature.builder()
.address(new Address(delegate.getAddress()).toSCAddress())
.address(toAuthScAddress(delegate.getAddress()))
.signature(delegate.getSignature() != null ? delegate.getSignature() : Scv.toVoid())
.nestedDelegates(buildDelegateNodes(delegate.getNestedDelegates()))
.build();
Expand Down Expand Up @@ -878,6 +884,31 @@ private static int fillMatchingDelegateNodes(
return filled;
}

private static final String AUTHORIZATION_ADDRESS_ERROR =
"Authorization address must be a classic account (G...) or contract (C...) address";

/**
* Converts an address that takes part in authorization to its {@link SCAddress}. Only accounts
* ({@code G...}) and contracts ({@code C...}) can authorize; the host rejects any other address
* type in credentials and delegate signers.
*/
private static SCAddress toAuthScAddress(String address) {
Address parsed = new Address(address);
if (parsed.getAddressType() != Address.AddressType.ACCOUNT
&& parsed.getAddressType() != Address.AddressType.CONTRACT) {
throw new IllegalArgumentException(AUTHORIZATION_ADDRESS_ERROR + ", got " + address);
}
return parsed.toSCAddress();
}

private static void ensureAuthorizationScAddress(SCAddress address) {
if (address.getDiscriminant() != SCAddressType.SC_ADDRESS_TYPE_ACCOUNT
&& address.getDiscriminant() != SCAddressType.SC_ADDRESS_TYPE_CONTRACT) {
throw new IllegalArgumentException(
AUTHORIZATION_ADDRESS_ERROR + ", got " + address.getDiscriminant());
}
}

private static byte[] scAddressXdrBytes(SCAddress address) {
try {
return address.toXdrByteArray();
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/org/stellar/sdk/SorobanServer.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import org.stellar.sdk.xdr.SCAddress;
import org.stellar.sdk.xdr.SCAddressType;
import org.stellar.sdk.xdr.SCContractInstance;
import org.stellar.sdk.xdr.SCMapEntry;
import org.stellar.sdk.xdr.SCVal;
import org.stellar.sdk.xdr.SCValType;
import org.stellar.sdk.xdr.SorobanAuthorizationEntry;
Expand Down Expand Up @@ -208,6 +209,8 @@ public GetFeeStatsResponse getFeeStats() {
* {@link Durability#TEMPORARY} or {@link Durability#PERSISTENT}.
* @return A {@link GetLedgerEntriesResponse.LedgerEntryResult} object containing the ledger entry
* result.
* @throws IllegalArgumentException If {@code contractId} is not a contract address ({@code
* C...}), or {@code key} contains a muxed address ({@code M...} or {@code W...}).
* @throws org.stellar.sdk.exception.NetworkException All the exceptions below are subclasses of
* NetworkException
* @throws SorobanRpcException If the Stellar RPC instance returns an error response.
Expand All @@ -217,6 +220,14 @@ public GetFeeStatsResponse getFeeStats() {
*/
public Optional<GetLedgerEntriesResponse.LedgerEntryResult> getContractData(
String contractId, SCVal key, Durability durability) {
if (!StrKey.isValidContract(contractId)) {
throw new IllegalArgumentException("Invalid contract ID: " + contractId);
}
if (containsMuxedAddress(key)) {
// The host rejects muxed addresses anywhere in a storage key, so no such entry can exist.
throw new IllegalArgumentException(
"Contract data keys cannot contain muxed addresses (M... or W...)");
}

ContractDataDurability contractDataDurability;
switch (durability) {
Expand Down Expand Up @@ -1226,6 +1237,33 @@ private <T, R> R sendRequest(
}
}

private static boolean containsMuxedAddress(SCVal val) {
switch (val.getDiscriminant()) {
case SCV_ADDRESS:
SCAddressType type = val.getAddress().getDiscriminant();
return type == SCAddressType.SC_ADDRESS_TYPE_MUXED_ACCOUNT
|| type == SCAddressType.SC_ADDRESS_TYPE_MUXED_CONTRACT;
case SCV_VEC:
if (val.getVec() != null) {
for (SCVal element : val.getVec().getSCVec()) {
if (containsMuxedAddress(element)) return true;
}
}
return false;
case SCV_MAP:
if (val.getMap() != null) {
for (SCMapEntry entry : val.getMap().getSCMap()) {
if (containsMuxedAddress(entry.getKey()) || containsMuxedAddress(entry.getVal())) {
return true;
}
}
}
return false;
default:
return false;
}
}

@Override
public void close() throws IOException {
this.httpClient.connectionPool().evictAll();
Expand Down
41 changes: 40 additions & 1 deletion src/main/java/org/stellar/sdk/StrKey.java
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,43 @@ public static boolean isValidMed25519PublicKey(String med25519PublicKey) {
}
}

/**
* Encodes raw data to strkey Stellar muxed contract address (W...)
*
* @param data data to encode, the 32-byte contract ID followed by the 8-byte big-endian ID
* @return "W..." representation of the muxed contract address
*/
public static String encodeMuxedContract(byte[] data) {
char[] encoded = encodeCheck(VersionByte.MUXED_CONTRACT, data);
return String.valueOf(encoded);
}

/**
* Decodes strkey Stellar muxed contract address (W...) to raw bytes
*
* @param data data to decode
* @return raw bytes, the 32-byte contract ID followed by the 8-byte big-endian ID
*/
public static byte[] decodeMuxedContract(String data) {
return decodeCheck(VersionByte.MUXED_CONTRACT, data.toCharArray());
}

/**
* Checks validity of muxed contract address (W...).
*
* @param muxedContract the muxed contract address to check
* @return true if the given muxed contract address is a valid muxed contract address, false
* otherwise
*/
public static boolean isValidMuxedContract(String muxedContract) {
try {
decodeMuxedContract(muxedContract);
return true;
} catch (Exception e) {
return false;
}
}

static VersionByte decodeVersionByte(String data) {
byte[] dataBytes = data.getBytes(StandardCharsets.UTF_8);
byte[] decoded = base32decode(dataBytes);
Expand Down Expand Up @@ -440,6 +477,7 @@ static byte[] decodeCheck(VersionByte versionByte, char[] encoded) {
}
break;
case MED25519_PUBLIC_KEY:
case MUXED_CONTRACT:
if (data.length != 32 + 8) {
throw new IllegalArgumentException(
"Invalid data length, expected 40 bytes, got " + data.length);
Expand Down Expand Up @@ -675,7 +713,8 @@ enum VersionByte {
SIGNED_PAYLOAD((byte) (15 << 3)), // P
CONTRACT((byte) (2 << 3)), // C
LIQUIDITY_POOL((byte) (11 << 3)), // L
CLAIMABLE_BALANCE((byte) (1 << 3)); // B
CLAIMABLE_BALANCE((byte) (1 << 3)), // B
MUXED_CONTRACT((byte) (22 << 3)); // W

private final byte value;

Expand Down
17 changes: 11 additions & 6 deletions src/main/java/org/stellar/sdk/TransactionBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -446,9 +446,9 @@ public Transaction buildPaymentToContractTransaction(
* SorobanDataBuilder.Resources(0L, 500L, 500L)} and {@code resourceFee} set to {@code
* 4_000_000L}.
*
* @param balanceOwner The owner of the asset, it should be the same as the `destination` address
* in the {@link #buildPaymentToContractTransaction(String, Asset, BigDecimal,
* SorobanDataBuilder.Resources, Long, String)} method.
* @param balanceOwner The contract address ({@code C...}) that owns the balance, it should be the
* same as the `destination` address in the {@link #buildPaymentToContractTransaction(String,
* Asset, BigDecimal, SorobanDataBuilder.Resources, Long, String)} method.
* @param asset The asset
* @param source The source account for the transaction.
* @return The transaction.
Expand All @@ -470,9 +470,9 @@ public Transaction buildRestoreAssetBalanceEntryTransaction(
* <p>Under Protocol 22, if the entry really needs to be restored, then this method will consume
* about 0.25 XLM in resource fees.
*
* @param balanceOwner The owner of the asset, it should be the same as the `destination` address
* in the {@link #buildPaymentToContractTransaction(String, Asset, BigDecimal,
* SorobanDataBuilder.Resources, Long, String)} method.
* @param balanceOwner The contract address ({@code C...}) that owns the balance, it should be the
* same as the `destination` address in the {@link #buildPaymentToContractTransaction(String,
* Asset, BigDecimal, SorobanDataBuilder.Resources, Long, String)} method.
* @param asset The asset
* @param resources The resources required for the transaction.
* @param resourceFee The maximum fee (in stroops) that can be paid for the transaction.
Expand All @@ -485,6 +485,11 @@ public Transaction buildRestoreAssetBalanceEntryTransaction(
SorobanDataBuilder.Resources resources,
Long resourceFee,
@Nullable String source) {
// Only contracts hold SAC balances in contract data; an account's balance is its trustline.
if (!StrKey.isValidContract(balanceOwner)) {
throw new IllegalArgumentException(
"balanceOwner is not a valid contract address: " + balanceOwner);
}
String contractId = asset.getContractId(this.network);
LedgerKey balanceLedgerKey =
LedgerKey.builder()
Expand Down
Loading
Loading