diff --git a/CHANGELOG.md b/CHANGELOG.md index 43b028938..c49c24d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/main/java/org/stellar/sdk/Address.java b/src/main/java/org/stellar/sdk/Address.java index 41a12e7f8..aaf42743f 100644 --- a/src/main/java/org/stellar/sdk/Address.java +++ b/src/main/java/org/stellar/sdk/Address.java @@ -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; @@ -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 { @@ -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. */ @@ -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"); } @@ -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. * @@ -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"); } @@ -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"); } @@ -225,9 +262,20 @@ public SCVal toSCVal() { } /** - * Returns the byte array of the Stellar public key or contract ID. + * Returns the raw bytes of this address, as encoded in its StrKey: + * + *
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.
@@ -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()
diff --git a/src/main/java/org/stellar/sdk/scval/ScvComparator.java b/src/main/java/org/stellar/sdk/scval/ScvComparator.java
index a0b7de5b2..63ce19a2d 100644
--- a/src/main/java/org/stellar/sdk/scval/ScvComparator.java
+++ b/src/main/java/org/stellar/sdk/scval/ScvComparator.java
@@ -262,6 +262,19 @@ static int compareScAddress(SCAddress a, SCAddress b) {
return Util.compareBytesUnsigned(
a.getLiquidityPoolId().getPoolID().getHash(),
b.getLiquidityPoolId().getPoolID().getHash());
+ case SC_ADDRESS_TYPE_MUXED_CONTRACT:
+ {
+ cmp =
+ a.getMuxedContract()
+ .getId()
+ .getUint64()
+ .getNumber()
+ .compareTo(b.getMuxedContract().getId().getUint64().getNumber());
+ if (cmp != 0) return cmp;
+ return Util.compareBytesUnsigned(
+ a.getMuxedContract().getContractId().getContractID().getHash(),
+ b.getMuxedContract().getContractId().getContractID().getHash());
+ }
default:
throw new IllegalArgumentException("Unsupported SCAddress type: " + a.getDiscriminant());
}
diff --git a/src/test/java/org/stellar/sdk/AuthTest.java b/src/test/java/org/stellar/sdk/AuthTest.java
index 50831fe48..477f0161f 100644
--- a/src/test/java/org/stellar/sdk/AuthTest.java
+++ b/src/test/java/org/stellar/sdk/AuthTest.java
@@ -1236,6 +1236,119 @@ public void testBuildWithDelegatesEntrySortsDelegatesByAddress() throws IOExcept
assertEquals(new Address(gHigh).toSCAddress(), nested[1].getAddress());
}
+ private static final String MUXED_CONTRACT_ADDRESS =
+ "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC";
+
+ @Test
+ public void testAuthorizeInvocationRejectsMuxedContractAddress() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ Auth.Signer entrySigner =
+ preimage ->
+ Auth.defaultAccountSignatureScVal(
+ signer.getAccountId(), signer.sign(Auth.authorizationPayloadHash(preimage)));
+ try {
+ Auth.authorizeInvocation(
+ entrySigner, MUXED_CONTRACT_ADDRESS, 654656L, buildInvocation(), Network.TESTNET);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("Authorization address must be a classic account"));
+ }
+ }
+
+ @Test
+ public void testAuthorizeInvocationRejectsMuxedAccountAddress() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ Auth.Signer entrySigner =
+ preimage ->
+ Auth.defaultAccountSignatureScVal(
+ signer.getAccountId(), signer.sign(Auth.authorizationPayloadHash(preimage)));
+ String muxedAccount =
+ new MuxedAccount(signer.getAccountId(), java.math.BigInteger.valueOf(42)).getAddress();
+ try {
+ Auth.authorizeInvocation(
+ entrySigner, muxedAccount, 654656L, buildInvocation(), Network.TESTNET);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("Authorization address must be a classic account"));
+ }
+ }
+
+ @Test
+ public void testSignAuthorizeEntryWithMuxedContractCredentialsThrows() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(MUXED_CONTRACT_ADDRESS).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.authorizeEntry(entry, signer, 654656L, Network.TESTNET);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("Authorization address must be a classic account"));
+ }
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntryRejectsMuxedContractCredentials() {
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(MUXED_CONTRACT_ADDRESS).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.buildWithDelegatesEntry(entry, 654656L, Collections.emptyList(), null);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("Authorization address must be a classic account"));
+ }
+ }
+
+ @Test
+ public void testSignAuthorizeEntryWithMuxedContractForAddressThrows() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.authorizeEntry(entry, signer, 654656L, Network.TESTNET, MUXED_CONTRACT_ADDRESS);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("Authorization address must be a classic account"));
+ }
+ }
+
+ @Test
+ public void testBuildWithDelegatesEntryRejectsMuxedContractDelegate() {
+ KeyPair signer =
+ KeyPair.fromSecretSeed("SAEZSI6DY7AXJFIYA4PM6SIBNEYYXIEM2MSOTHFGKHDW32MBQ7KVO6EN");
+ SorobanAuthorizationEntry entry =
+ SorobanAuthorizationEntry.builder()
+ .credentials(
+ buildAddressV2Credentials(new Address(signer.getAccountId()).toSCAddress(), 0L))
+ .rootInvocation(buildInvocation())
+ .build();
+ try {
+ Auth.buildWithDelegatesEntry(
+ entry,
+ 654656L,
+ Collections.singletonList(new Auth.DelegateSignature(MUXED_CONTRACT_ADDRESS, null, null)),
+ null);
+ fail();
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage().contains("Authorization address must be a classic account"));
+ }
+ }
+
@Test
public void testBuildWithDelegatesEntryWithDuplicateDelegateAddressThrows() {
KeyPair signer =
diff --git a/src/test/java/org/stellar/sdk/SorobanServerTest.java b/src/test/java/org/stellar/sdk/SorobanServerTest.java
index bb105018c..bb647e190 100644
--- a/src/test/java/org/stellar/sdk/SorobanServerTest.java
+++ b/src/test/java/org/stellar/sdk/SorobanServerTest.java
@@ -17,6 +17,7 @@
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Optional;
import okhttp3.HttpUrl;
@@ -53,6 +54,7 @@
import org.stellar.sdk.responses.sorobanrpc.GetVersionInfoResponse;
import org.stellar.sdk.responses.sorobanrpc.SendTransactionResponse;
import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse;
+import org.stellar.sdk.scval.Scv;
import org.stellar.sdk.xdr.ContractDataDurability;
import org.stellar.sdk.xdr.ContractExecutable;
import org.stellar.sdk.xdr.ContractExecutableType;
@@ -1699,6 +1701,45 @@ public MockResponse dispatch(@NotNull RecordedRequest recordedRequest)
mockWebServer.close();
}
+ @Test
+ public void testGetContractDataRejectsKeyContainingMuxedAddress() throws IOException {
+ String contractId = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA";
+ SCVal muxedContract =
+ Scv.toAddress("WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC");
+ SCVal muxedAccount =
+ Scv.toAddress("MAQAA5L65LSYH7CQ3VTJ7F3HHLGCL3DSLAR2Y47263D56MNNGHSQSAAAAAAAAAAE2LP26");
+ LinkedHashMap