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..4786ed603 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"); } @@ -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"); } @@ -276,6 +315,7 @@ public enum AddressType { CONTRACT, MUXED_ACCOUNT, CLAIMABLE_BALANCE, - LIQUIDITY_POOL + LIQUIDITY_POOL, + MUXED_CONTRACT } } diff --git a/src/main/java/org/stellar/sdk/Auth.java b/src/main/java/org/stellar/sdk/Auth.java index 994745d98..ae122463d 100644 --- a/src/main/java/org/stellar/sdk/Auth.java +++ b/src/main/java/org/stellar/sdk/Auth.java @@ -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; @@ -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( @@ -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()) @@ -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) { @@ -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()) { @@ -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, @@ -775,6 +780,7 @@ public static SorobanAuthorizationEntry buildWithDelegatesEntry( + credentials.getDiscriminant()); } SorobanAddressCredentials addressCredentials = getAddressCredentials(credentials); + ensureAuthorizationScAddress(addressCredentials.getAddress()); return SorobanAuthorizationEntry.builder() .credentials( @@ -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(); @@ -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(); diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 8c43e1c32..ef6c85877 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -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; @@ -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. @@ -217,6 +220,14 @@ public GetFeeStatsResponse getFeeStats() { */ public Optional 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) { @@ -1226,6 +1237,33 @@ private 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(); diff --git a/src/main/java/org/stellar/sdk/StrKey.java b/src/main/java/org/stellar/sdk/StrKey.java index 1e97dfd91..e1916e075 100644 --- a/src/main/java/org/stellar/sdk/StrKey.java +++ b/src/main/java/org/stellar/sdk/StrKey.java @@ -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); @@ -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); @@ -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; diff --git a/src/main/java/org/stellar/sdk/TransactionBuilder.java b/src/main/java/org/stellar/sdk/TransactionBuilder.java index 4199bd3b7..658717639 100644 --- a/src/main/java/org/stellar/sdk/TransactionBuilder.java +++ b/src/main/java/org/stellar/sdk/TransactionBuilder.java @@ -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. @@ -470,9 +470,9 @@ public Transaction buildRestoreAssetBalanceEntryTransaction( *

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 nestedMap = new LinkedHashMap<>(); + nestedMap.put(Scv.toSymbol("owner"), Scv.toVec(singletonList(muxedAccount))); + List keys = + Arrays.asList( + muxedContract, + Scv.toVec(Arrays.asList(Scv.toSymbol("Balance"), muxedContract)), + Scv.toMap(nestedMap)); + try (SorobanServer server = new SorobanServer("http://localhost:8000/rpc")) { + for (SCVal key : keys) { + try { + server.getContractData(contractId, key, SorobanServer.Durability.PERSISTENT); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("cannot contain muxed addresses")); + } + } + } + } + + @Test + public void testGetContractDataRejectsMuxedContract() throws IOException { + try (SorobanServer server = new SorobanServer("http://localhost:8000/rpc")) { + server.getContractData( + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC", + Scv.toSymbol("COUNTER"), + SorobanServer.Durability.PERSISTENT); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("Invalid contract ID")); + } + } + private Transaction buildSorobanTransaction( SorobanTransactionData sorobanData, List auth) { String contractId = "CDU3PZ4LXVETIFVLS33RDXLD63JZ5GXS7PCV2DJ7BBT6EBPA2AB7YR5H"; diff --git a/src/test/java/org/stellar/sdk/TransactionBuilderTest.java b/src/test/java/org/stellar/sdk/TransactionBuilderTest.java index 0cf3e1310..c568747f6 100644 --- a/src/test/java/org/stellar/sdk/TransactionBuilderTest.java +++ b/src/test/java/org/stellar/sdk/TransactionBuilderTest.java @@ -1069,4 +1069,32 @@ private static org.stellar.sdk.xdr.TimeBounds buildTimeBounds( .maxTime(new TimePoint(new Uint64(new XdrUnsignedHyperInteger(maxTime)))) .build(); } + + @Test + public void testBuildRestoreAssetBalanceEntryTransactionRejectsAccount() { + Account account = new Account("GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", 1L); + TransactionBuilder builder = new TransactionBuilder(account, Network.TESTNET); + try { + builder.buildRestoreAssetBalanceEntryTransaction( + account.getAccountId(), Asset.createNativeAsset(), null); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("balanceOwner is not a valid contract address")); + } + } + + @Test + public void testBuildRestoreAssetBalanceEntryTransactionRejectsMuxedContract() { + Account account = new Account("GDJJRRMBK4IWLEPJGIE6SXD2LP7REGZODU7WDC3I2D6MR37F4XSHBKX2", 1L); + TransactionBuilder builder = new TransactionBuilder(account, Network.TESTNET); + try { + builder.buildRestoreAssetBalanceEntryTransaction( + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC", + Asset.createNativeAsset(), + null); + fail(); + } catch (IllegalArgumentException e) { + assertTrue(e.getMessage().contains("balanceOwner is not a valid contract address")); + } + } } diff --git a/src/test/java/org/stellar/sdk/TransactionTest.java b/src/test/java/org/stellar/sdk/TransactionTest.java index 7904233e6..3ff48aad1 100644 --- a/src/test/java/org/stellar/sdk/TransactionTest.java +++ b/src/test/java/org/stellar/sdk/TransactionTest.java @@ -10,12 +10,16 @@ import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.ByteBuffer; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import org.junit.Test; +import org.stellar.sdk.contract.AssembledTransaction; +import org.stellar.sdk.contract.ContractClient; import org.stellar.sdk.operations.BumpSequenceOperation; import org.stellar.sdk.operations.CreateAccountOperation; import org.stellar.sdk.operations.ExtendFootprintTTLOperation; @@ -24,6 +28,7 @@ import org.stellar.sdk.operations.RestoreFootprintOperation; import org.stellar.sdk.scval.Scv; import org.stellar.sdk.xdr.ContractDataDurability; +import org.stellar.sdk.xdr.ContractEvent; import org.stellar.sdk.xdr.ContractExecutable; import org.stellar.sdk.xdr.ContractExecutableType; import org.stellar.sdk.xdr.ContractIDPreimage; @@ -739,6 +744,66 @@ public void testIntegrationPaymentToContractTransactionWithDifferentSource() thr server.close(); } + @Test + public void testIntegrationTransferToMuxedContract() throws IOException { + Asset asset = Asset.createNativeAsset(); + KeyPair kp = KeyPair.random(); + IntegrationUtils.fundAccount(kp.getAccountId()); + IntegrationUtils.createAssetContract(asset, kp); + String contractId = IntegrationUtils.getRandomContractId(kp); + String muxedContractId = + Address.fromMuxedContract( + ByteBuffer.allocate(40) + .put(StrKey.decodeContract(contractId)) + .putLong(Long.MIN_VALUE) // 2^63, exceeds the max signed 64-bit integer + .array()) + .toString(); + assertTrue(muxedContractId.startsWith("W")); + + // SAC `transfer` takes a MuxedAddress `to`; the balance is credited to the base contract. + AssembledTransaction transfer; + try (ContractClient client = + new ContractClient( + asset.getContractId(IntegrationUtils.NETWORK), + IntegrationUtils.RPC_URL, + IntegrationUtils.NETWORK)) { + transfer = + client.invoke( + "transfer", + Arrays.asList( + Scv.toAddress(kp.getAccountId()), + Scv.toAddress(muxedContractId), + Scv.toInt128(BigInteger.valueOf(1001250000L))), + kp.getAccountId(), + kp, + null, + 100); + transfer.signAndSubmit(kp, false); + } + + // CAP-67 event format: the `to` topic is the base contract, and the data carries the mux ID. + List transferEvents = new ArrayList<>(); + for (ContractEvent event : + transfer.getGetTransactionResponse().getEvents().parseContractEventsXdr().get(0)) { + SCVal[] topics = event.getBody().getV0().getTopics(); + if (Scv.toSymbol("transfer").equals(topics[0])) { + transferEvents.add(event); + } + } + assertEquals(1, transferEvents.size()); + ContractEvent.ContractEventBody.ContractEventV0 transferEvent = + transferEvents.get(0).getBody().getV0(); + assertEquals(Scv.toAddress(contractId), transferEvent.getTopics()[2]); + LinkedHashMap data = Scv.fromMap(transferEvent.getData()); + assertEquals(BigInteger.valueOf(1001250000L), Scv.fromInt128(data.get(Scv.toSymbol("amount")))); + assertEquals( + new BigInteger("9223372036854775808"), + Scv.fromUint64(data.get(Scv.toSymbol("to_muxed_id")))); + + BigInteger balance = IntegrationUtils.getBalanceForContract(contractId, asset, kp); + assertEquals(1001250000L, balance.longValue()); + } + @Test public void testSignExtraSignersPayloadOneExtraSigner() { KeyPair source = diff --git a/src/test/kotlin/org/stellar/sdk/AddressTest.kt b/src/test/kotlin/org/stellar/sdk/AddressTest.kt index c2bacb866..c2c45ef6c 100644 --- a/src/test/kotlin/org/stellar/sdk/AddressTest.kt +++ b/src/test/kotlin/org/stellar/sdk/AddressTest.kt @@ -4,8 +4,10 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe import java.io.ByteArrayInputStream +import java.math.BigInteger import java.util.Base64 import org.stellar.sdk.xdr.SCAddress +import org.stellar.sdk.xdr.SCAddressType import org.stellar.sdk.xdr.SCVal import org.stellar.sdk.xdr.XdrDataInputStream @@ -16,6 +18,10 @@ class AddressTest : val muxedAccountId = "MAQAA5L65LSYH7CQ3VTJ7F3HHLGCL3DSLAR2Y47263D56MNNGHSQSAAAAAAAAAAE2LP26" val claimableBalanceId = "BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TU" val liquidityPoolId = "LA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUPJN" + // https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0023.md + val muxedContractId = "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC" + val muxedContractIdWithLargeId = + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAACWJY" val secretKey = "SB65MHFA2Z342DX4FNKHH2KCNR5JRM7GIVTWQLKG5Z6L3AAH4UZLZV4E" val invalidAddress = "GINVALID" @@ -58,6 +64,12 @@ class AddressTest : exception.message shouldBe "Unsupported address type" } + test("should create address from muxed contract address") { + val address = Address(muxedContractId) + address.toString() shouldBe muxedContractId + address.addressType shouldBe Address.AddressType.MUXED_CONTRACT + } + test("should throw exception for invalid address") { val exception = shouldThrow { Address(invalidAddress) } exception.message shouldBe "Unsupported address type" @@ -220,6 +232,44 @@ class AddressTest : } } + context("muxed contract") { + test("should create address from muxed contract bytes") { + val bytes = StrKey.decodeMuxedContract(muxedContractId) + val address = Address.fromMuxedContract(bytes) + address.toString() shouldBe muxedContractId + address.addressType shouldBe Address.AddressType.MUXED_CONTRACT + address.bytes shouldBe bytes + } + + test("should convert muxed contract to SCAddress") { + val scAddress = Address(muxedContractIdWithLargeId).toSCAddress() + scAddress.discriminant shouldBe SCAddressType.SC_ADDRESS_TYPE_MUXED_CONTRACT + scAddress.muxedContract.id.uint64.number shouldBe BigInteger("9223372036854775808") + scAddress.muxedContract.contractId.contractID.hash shouldBe + StrKey.decodeContract(contractId) + } + + test("should round trip through SCAddress XDR") { + for (strKey in listOf(muxedContractId, muxedContractIdWithLargeId)) { + val xdr = Address(strKey).toSCAddress().toXdrBase64() + val address = Address.fromSCAddress(SCAddress.fromXdrBase64(xdr)) + address.toString() shouldBe strKey + address.addressType shouldBe Address.AddressType.MUXED_CONTRACT + } + } + + test("should round trip through SCVal") { + val address = Address.fromSCVal(Address(muxedContractId).toSCVal()) + address.toString() shouldBe muxedContractId + } + + test("should be equal by value and differ from the base contract") { + Address(muxedContractId) shouldBe Address(muxedContractId) + (Address(muxedContractId) == Address(muxedContractIdWithLargeId)) shouldBe false + (Address(muxedContractId) == Address(contractId)) shouldBe false + } + } + context("SCVal conversion") { test("should convert address to SCVal") { val address = Address(contractId) diff --git a/src/test/kotlin/org/stellar/sdk/StrKeyTest.kt b/src/test/kotlin/org/stellar/sdk/StrKeyTest.kt index b7b34ba47..c8aa9e573 100644 --- a/src/test/kotlin/org/stellar/sdk/StrKeyTest.kt +++ b/src/test/kotlin/org/stellar/sdk/StrKeyTest.kt @@ -236,6 +236,41 @@ class StrKeyTest : } } + context("MUXED_CONTRACT") { + // https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0023.md + test("encode and decode") { + val rawKey = + Util.hexToBytes( + "3f0c34bf93ad0d9971d04ccc90f705511c838aad9734a4a2fb0d7a03fc7fe89a0000000000000000" + ) + val encoded = "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC" + StrKey.encodeMuxedContract(rawKey) shouldBe encoded + StrKey.decodeMuxedContract(encoded) shouldBe rawKey + } + + test("encode and decode with id exceeding max signed 64-bit integer") { + val rawKey = + Util.hexToBytes( + "3f0c34bf93ad0d9971d04ccc90f705511c838aad9734a4a2fb0d7a03fc7fe89a8000000000000000" + ) + val encoded = "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAACWJY" + StrKey.encodeMuxedContract(rawKey) shouldBe encoded + StrKey.decodeMuxedContract(encoded) shouldBe rawKey + } + + test("is valid") { + StrKey.isValidMuxedContract( + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC" + ) shouldBe true + StrKey.isValidMuxedContract( + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + ) shouldBe false + StrKey.isValidMuxedContract( + "MA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJVAAAAAAAAAAAAAJLK" + ) shouldBe false + } + } + context("SIGNED_PAYLOAD") { test("encode and decode with 64-byte payload") { val rawKey = @@ -372,6 +407,11 @@ class StrKeyTest : "BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TV", // Invalid claimable balance type (first byte of binary key is not 0) "BAAT6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGXACA", + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWD", + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWCA", + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAAIOUI", + "W47QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAADXHW", + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWA", "", ) { key -> shouldThrow { StrKey.decodeEd25519PublicKey(key) } @@ -383,6 +423,7 @@ class StrKeyTest : shouldThrow { StrKey.decodeContract(key) } shouldThrow { StrKey.decodeLiquidityPool(key) } shouldThrow { StrKey.decodeClaimableBalance(key) } + shouldThrow { StrKey.decodeMuxedContract(key) } } } @@ -408,6 +449,9 @@ class StrKeyTest : StrKey.decodeVersionByte( "BAAD6DBUX6J22DMZOHIEZTEQ64CVCHEDRKWZONFEUL5Q26QD7R76RGR4TU" ) shouldBe StrKey.VersionByte.CLAIMABLE_BALANCE + StrKey.decodeVersionByte( + "WA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUAAAAAAAAAAAAAWWC" + ) shouldBe StrKey.VersionByte.MUXED_CONTRACT } test("decode version byte throws exception for invalid input") { diff --git a/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt b/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt index e89d69818..2144ff9d1 100644 --- a/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt +++ b/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt @@ -14,6 +14,7 @@ import org.stellar.sdk.xdr.ContractExecutableType import org.stellar.sdk.xdr.ContractID import org.stellar.sdk.xdr.Hash import org.stellar.sdk.xdr.Int64 +import org.stellar.sdk.xdr.MuxedContract import org.stellar.sdk.xdr.MuxedEd25519Account import org.stellar.sdk.xdr.PoolID import org.stellar.sdk.xdr.PublicKey @@ -417,7 +418,9 @@ class ScvComparatorTest : } context("compareScAddress") { - test("address type ordering: account < contract < muxed < claimable < pool") { + test( + "address type ordering: account < contract < muxed < claimable < pool < muxed contract" + ) { val addrs = arrayOf( accountAddress(bytes32(0x00)), @@ -425,6 +428,7 @@ class ScvComparatorTest : muxedAddress(0, bytes32(0x00)), claimableBalanceAddress(bytes32(0x00)), liquidityPoolAddress(bytes32(0x00)), + muxedContractAddress(0, bytes32(0x00)), ) for (i in 0 until addrs.size - 1) { ScvComparator.compareScAddress(addrs[i], addrs[i + 1]) shouldBeLessThan 0 @@ -481,6 +485,21 @@ class ScvComparatorTest : muxedAddress(5, bytes32(0xAB)), ) shouldBe 0 } + + test("muxed contract: id first, then contract ID") { + ScvComparator.compareScAddress( + muxedContractAddress(1, bytes32(0xFF)), + muxedContractAddress(2, bytes32(0x00)), + ) shouldBeLessThan 0 + ScvComparator.compareScAddress( + muxedContractAddress(1, bytes32(0x00)), + muxedContractAddress(1, bytes32Last(0x01)), + ) shouldBeLessThan 0 + ScvComparator.compareScAddress( + muxedContractAddress(5, bytes32(0xAB)), + muxedContractAddress(5, bytes32(0xAB)), + ) shouldBe 0 + } } context("compareContractExecutable") { @@ -728,5 +747,16 @@ class ScvComparatorTest : .discriminant(SCAddressType.SC_ADDRESS_TYPE_LIQUIDITY_POOL) .liquidityPoolId(PoolID(Hash(hash))) .build() + + private fun muxedContractAddress(id: Long, hash: ByteArray): SCAddress = + SCAddress.builder() + .discriminant(SCAddressType.SC_ADDRESS_TYPE_MUXED_CONTRACT) + .muxedContract( + MuxedContract.builder() + .id(Uint64(XdrUnsignedHyperInteger(BigInteger.valueOf(id)))) + .contractId(ContractID(Hash(hash))) + .build() + ) + .build() } }