From 0a6a924ed206913ab7fa720c9f3d475c640a9d1b Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Sun, 24 May 2026 11:51:17 +0800 Subject: [PATCH 1/7] feat: add SEP-46/47/48 contract introspection support --- CHANGELOG.md | 3 + .../java/org/stellar/sdk/SorobanServer.java | 226 ++++++++++++ .../stellar/sdk/contract/ContractInfo.java | 107 ++++++ .../stellar/sdk/contract/ContractMeta.java | 245 +++++++++++++ .../stellar/sdk/contract/ContractSpec.java | 282 +++++++++++++++ .../sdk/contract/WasmCustomSections.java | 149 ++++++++ .../org/stellar/sdk/contract/XdrStreams.java | 108 ++++++ .../ContractCodeNotFoundException.java | 20 ++ .../ContractInstanceNotFoundException.java | 14 + .../ContractIntrospectionException.java | 14 + .../ContractWasmRetrievalException.java | 12 + .../exception/InvalidWasmException.java | 12 + ...tellarAssetContractHasNoWasmException.java | 17 + .../sdk/contract/exception/package-info.java | 20 +- .../stellar/sdk/contract/ContractInfoTest.kt | 134 +++++++ .../stellar/sdk/contract/ContractMetaTest.kt | 130 +++++++ .../stellar/sdk/contract/ContractSpecTest.kt | 222 ++++++++++++ .../SorobanServerContractIntrospectionTest.kt | 340 ++++++++++++++++++ .../sdk/contract/WasmCustomSectionsTest.kt | 125 +++++++ .../stellar/sdk/contract/WasmTestSupport.kt | 58 +++ .../stellar/sdk/contract/XdrStreamsTest.kt | 59 +++ 21 files changed, 2291 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/stellar/sdk/contract/ContractInfo.java create mode 100644 src/main/java/org/stellar/sdk/contract/ContractMeta.java create mode 100644 src/main/java/org/stellar/sdk/contract/ContractSpec.java create mode 100644 src/main/java/org/stellar/sdk/contract/WasmCustomSections.java create mode 100644 src/main/java/org/stellar/sdk/contract/XdrStreams.java create mode 100644 src/main/java/org/stellar/sdk/contract/exception/ContractCodeNotFoundException.java create mode 100644 src/main/java/org/stellar/sdk/contract/exception/ContractInstanceNotFoundException.java create mode 100644 src/main/java/org/stellar/sdk/contract/exception/ContractIntrospectionException.java create mode 100644 src/main/java/org/stellar/sdk/contract/exception/ContractWasmRetrievalException.java create mode 100644 src/main/java/org/stellar/sdk/contract/exception/InvalidWasmException.java create mode 100644 src/main/java/org/stellar/sdk/contract/exception/StellarAssetContractHasNoWasmException.java create mode 100644 src/test/kotlin/org/stellar/sdk/contract/ContractInfoTest.kt create mode 100644 src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt create mode 100644 src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt create mode 100644 src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt create mode 100644 src/test/kotlin/org/stellar/sdk/contract/WasmCustomSectionsTest.kt create mode 100644 src/test/kotlin/org/stellar/sdk/contract/WasmTestSupport.kt create mode 100644 src/test/kotlin/org/stellar/sdk/contract/XdrStreamsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index be8379998..11d1565ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Pending +### Update +- feat: add SEP-0046, SEP-0047, and SEP-0048 contract introspection support. New `ContractMeta`, `ContractSpec`, and `ContractInfo` wrappers under `org.stellar.sdk.contract` parse contract Wasm metadata and interface specs locally. `SorobanServer` adds `getContractWasm`, `getContractWasmByHash`, `getContractMeta`, `getContractSpec`, and `getContractInfo` for RPC-backed retrieval. + ## 3.0.0 This release contains the exact same content as 3.0.0-beta1. Below is the changelog since 2.2.3. diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 0442c36bc..223a89553 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -22,6 +22,13 @@ import okhttp3.RequestBody; import okhttp3.Response; import org.jetbrains.annotations.Nullable; +import org.stellar.sdk.contract.ContractInfo; +import org.stellar.sdk.contract.ContractMeta; +import org.stellar.sdk.contract.ContractSpec; +import org.stellar.sdk.contract.exception.ContractCodeNotFoundException; +import org.stellar.sdk.contract.exception.ContractInstanceNotFoundException; +import org.stellar.sdk.contract.exception.ContractWasmRetrievalException; +import org.stellar.sdk.contract.exception.StellarAssetContractHasNoWasmException; import org.stellar.sdk.exception.AccountNotFoundException; import org.stellar.sdk.exception.ConnectionErrorException; import org.stellar.sdk.exception.PrepareTransactionException; @@ -54,11 +61,17 @@ import org.stellar.sdk.responses.sorobanrpc.SimulateTransactionResponse; import org.stellar.sdk.responses.sorobanrpc.SorobanRpcResponse; import org.stellar.sdk.scval.Scv; +import org.stellar.sdk.xdr.ContractCodeEntry; import org.stellar.sdk.xdr.ContractDataDurability; +import org.stellar.sdk.xdr.ContractExecutable; +import org.stellar.sdk.xdr.ContractExecutableType; +import org.stellar.sdk.xdr.Hash; import org.stellar.sdk.xdr.LedgerEntry; import org.stellar.sdk.xdr.LedgerEntryType; import org.stellar.sdk.xdr.LedgerKey; +import org.stellar.sdk.xdr.SCContractInstance; import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SCValType; import org.stellar.sdk.xdr.SorobanAuthorizationEntry; import org.stellar.sdk.xdr.SorobanTransactionData; @@ -693,6 +706,219 @@ public GetSACBalanceResponse getSACBalance(String contractId, Asset asset, Netwo .build(); } + /** + * Fetches the Wasm bytecode of a deployed contract by its contract ID. + * + *

This first reads the contract instance ledger entry to discover the executable, then fetches + * the {@code CONTRACT_CODE} ledger entry referenced by the instance. + * + * @param contractId The contract ID. Encoded as a Stellar Contract Address, e.g. + * "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5". + * @return The contract Wasm bytecode. + * @throws IllegalArgumentException If the contract ID is not a valid contract strkey. + * @throws ContractInstanceNotFoundException If the contract instance ledger entry does not exist. + * @throws StellarAssetContractHasNoWasmException If the contract is a Stellar Asset Contract, + * which has no Wasm. + * @throws ContractCodeNotFoundException If the contract code ledger entry does not exist or has + * been archived. + * @throws ContractWasmRetrievalException If the RPC response contains unexpected ledger entry + * data. + * @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. + * @throws RequestTimeoutException If the request timed out. + * @throws ConnectionErrorException When the request cannot be executed due to cancellation or + * connectivity problems, etc. + */ + public byte[] getContractWasm(String contractId) { + if (!StrKey.isValidContract(contractId)) { + throw new IllegalArgumentException("Invalid contract ID: " + contractId); + } + + Address address = new Address(contractId); + LedgerKey ledgerKey = + LedgerKey.builder() + .discriminant(LedgerEntryType.CONTRACT_DATA) + .contractData( + LedgerKey.LedgerKeyContractData.builder() + .contract(address.toSCAddress()) + .key(Scv.toLedgerKeyContractInstance()) + .durability(ContractDataDurability.PERSISTENT) + .build()) + .build(); + + GetLedgerEntriesResponse response = this.getLedgerEntries(Collections.singleton(ledgerKey)); + List entries = response.getEntries(); + if (entries == null || entries.isEmpty()) { + throw new ContractInstanceNotFoundException(contractId); + } + + LedgerEntry.LedgerEntryData ledgerEntryData = + parseLedgerEntryData( + entries.get(0).getXdr(), + "Failed to parse contract instance ledger entry, contractId: " + contractId); + if (ledgerEntryData.getDiscriminant() != LedgerEntryType.CONTRACT_DATA + || ledgerEntryData.getContractData() == null) { + throw new ContractWasmRetrievalException( + "Unexpected ledger entry type for contract instance, contractId: " + contractId); + } + + SCVal value = ledgerEntryData.getContractData().getVal(); + if (value == null + || value.getDiscriminant() != SCValType.SCV_CONTRACT_INSTANCE + || value.getInstance() == null) { + throw new ContractWasmRetrievalException( + "Unexpected ledger entry value for contract instance, contractId: " + contractId); + } + + SCContractInstance instance = value.getInstance(); + ContractExecutable executable = instance.getExecutable(); + if (executable == null || executable.getDiscriminant() == null) { + throw new ContractWasmRetrievalException( + "Contract instance is missing an executable, contractId: " + contractId); + } + + ContractExecutableType type = executable.getDiscriminant(); + if (type == ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) { + throw new StellarAssetContractHasNoWasmException(contractId); + } + if (type != ContractExecutableType.CONTRACT_EXECUTABLE_WASM) { + throw new ContractWasmRetrievalException( + "Unsupported contract executable type: " + type + ", contractId: " + contractId); + } + + Hash wasmHash = executable.getWasm_hash(); + if (wasmHash == null || wasmHash.getHash() == null) { + throw new ContractWasmRetrievalException( + "Contract instance is missing a Wasm hash, contractId: " + contractId); + } + return getContractWasmByHash(wasmHash.getHash()); + } + + /** + * Fetches the Wasm bytecode of a deployed contract by its Wasm hash. + * + * @param wasmHash The 32-byte Wasm hash. + * @return The contract Wasm bytecode. + * @throws IllegalArgumentException If {@code wasmHash} is null, not 32 bytes, or all zero. + * @throws ContractCodeNotFoundException If the contract code ledger entry does not exist or has + * been archived. + * @throws ContractWasmRetrievalException If the RPC response contains unexpected ledger entry + * data. + * @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. + * @throws RequestTimeoutException If the request timed out. + * @throws ConnectionErrorException When the request cannot be executed due to cancellation or + * connectivity problems, etc. + */ + public byte[] getContractWasmByHash(byte[] wasmHash) { + if (wasmHash == null) { + throw new IllegalArgumentException("wasmHash must not be null"); + } + if (wasmHash.length != 32) { + throw new IllegalArgumentException( + "wasmHash must be 32 bytes, got " + wasmHash.length + " bytes"); + } + boolean allZero = true; + for (byte b : wasmHash) { + if (b != 0) { + allZero = false; + break; + } + } + if (allZero) { + throw new IllegalArgumentException("wasmHash must not be all zero"); + } + + LedgerKey ledgerKey = + LedgerKey.builder() + .discriminant(LedgerEntryType.CONTRACT_CODE) + .contractCode( + LedgerKey.LedgerKeyContractCode.builder() + .hash(new Hash(Arrays.copyOf(wasmHash, wasmHash.length))) + .build()) + .build(); + + GetLedgerEntriesResponse response = this.getLedgerEntries(Collections.singleton(ledgerKey)); + List entries = response.getEntries(); + if (entries == null || entries.isEmpty()) { + throw new ContractCodeNotFoundException(Arrays.copyOf(wasmHash, wasmHash.length)); + } + + LedgerEntry.LedgerEntryData ledgerEntryData = + parseLedgerEntryData(entries.get(0).getXdr(), "Failed to parse contract code ledger entry"); + if (ledgerEntryData.getDiscriminant() != LedgerEntryType.CONTRACT_CODE + || ledgerEntryData.getContractCode() == null) { + throw new ContractWasmRetrievalException("Unexpected ledger entry type for contract code"); + } + + ContractCodeEntry codeEntry = ledgerEntryData.getContractCode(); + byte[] code = codeEntry.getCode(); + if (code == null) { + throw new ContractWasmRetrievalException("Contract code ledger entry has no code bytes"); + } + return code; + } + + /** + * Fetches and parses the SEP-0046 metadata of a deployed contract. + * + *

This method fetches the contract Wasm and parses it locally. When you need more than one + * introspection view from the same contract, prefer {@link #getContractInfo(String)} to avoid + * fetching the Wasm multiple times. + * + * @param contractId The contract ID, encoded as a Stellar Contract Address. + * @return The parsed {@link ContractMeta}. + * @see #getContractWasm(String) + */ + public ContractMeta getContractMeta(String contractId) { + return ContractMeta.fromWasm(getContractWasm(contractId)); + } + + /** + * Fetches and parses the SEP-0048 interface specification of a deployed contract. + * + *

This method fetches the contract Wasm and parses it locally. When you need more than one + * introspection view from the same contract, prefer {@link #getContractInfo(String)} to avoid + * fetching the Wasm multiple times. + * + * @param contractId The contract ID, encoded as a Stellar Contract Address. + * @return The parsed {@link ContractSpec}. + * @see #getContractWasm(String) + */ + public ContractSpec getContractSpec(String contractId) { + return ContractSpec.fromWasm(getContractWasm(contractId)); + } + + /** + * Fetches and parses the SEP-0046 metadata, SEP-0048 specification, and environment metadata of a + * deployed contract. + * + *

This method issues two RPC requests (one for the contract instance ledger entry, one for the + * contract code ledger entry) and parses the Wasm a single time. Prefer this over calling {@link + * #getContractMeta}, {@link #getContractSpec}, and this method separately when more than one view + * is needed. + * + * @param contractId The contract ID, encoded as a Stellar Contract Address. + * @return The parsed {@link ContractInfo}. + * @see #getContractWasm(String) + */ + public ContractInfo getContractInfo(String contractId) { + return ContractInfo.fromWasm(getContractWasm(contractId)); + } + + private static LedgerEntry.LedgerEntryData parseLedgerEntryData(String xdr, String errorContext) { + if (xdr == null || xdr.isEmpty()) { + throw new ContractWasmRetrievalException(errorContext + ": empty XDR payload"); + } + try { + return LedgerEntry.LedgerEntryData.fromXdrBase64(xdr); + } catch (IOException | IllegalArgumentException e) { + throw new ContractWasmRetrievalException(errorContext, e); + } + } + public static Transaction assembleTransaction( Transaction transaction, SimulateTransactionResponse simulateTransactionResponse) { if (!transaction.isSorobanTransaction()) { diff --git a/src/main/java/org/stellar/sdk/contract/ContractInfo.java b/src/main/java/org/stellar/sdk/contract/ContractInfo.java new file mode 100644 index 000000000..a2c3b0d52 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/ContractInfo.java @@ -0,0 +1,107 @@ +package org.stellar.sdk.contract; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.stellar.sdk.contract.exception.InvalidWasmException; +import org.stellar.sdk.xdr.SCEnvMetaEntry; +import org.stellar.sdk.xdr.SCMetaEntry; +import org.stellar.sdk.xdr.SCSpecEntry; + +/** + * Aggregates SEP-0046 contract metadata, SEP-0048 interface specification, and contract environment + * metadata read from a single Wasm module. + */ +@Getter +@EqualsAndHashCode +@ToString +public final class ContractInfo { + private final ContractMeta meta; + private final ContractSpec spec; + private final List envMeta; + + public ContractInfo(ContractMeta meta, ContractSpec spec) { + this(meta, spec, Collections.emptyList()); + } + + public ContractInfo(ContractMeta meta, ContractSpec spec, List envMeta) { + if (meta == null) { + throw new IllegalArgumentException("meta must not be null"); + } + if (spec == null) { + throw new IllegalArgumentException("spec must not be null"); + } + if (envMeta == null) { + throw new IllegalArgumentException("envMeta must not be null"); + } + List copy = new ArrayList<>(envMeta.size()); + for (SCEnvMetaEntry entry : envMeta) { + if (entry == null) { + throw new IllegalArgumentException("envMeta must not contain null elements"); + } + copy.add(entry); + } + this.meta = meta; + this.spec = spec; + this.envMeta = Collections.unmodifiableList(copy); + } + + /** + * Creates a {@link ContractInfo} from contract Wasm bytes. Scans the module once and reads {@code + * contractmetav0}, {@code contractspecv0}, and {@code contractenvmetav0} sections. + * + * @param wasm contract Wasm bytes + * @throws InvalidWasmException if the Wasm module or any introspection section cannot be decoded, + * or if multiple {@code contractspecv0} sections are present + */ + public static ContractInfo fromWasm(byte[] wasm) { + List metaEntries = new ArrayList<>(); + List specSections = new ArrayList<>(); + List envMetaEntries = new ArrayList<>(); + + for (Map.Entry section : WasmCustomSections.getCustomSections(wasm)) { + String name = section.getKey(); + byte[] payload = section.getValue(); + if (WasmCustomSections.CONTRACT_META_SECTION_NAME.equals(name)) { + metaEntries.addAll(XdrStreams.parseScMetaEntries(payload)); + } else if (WasmCustomSections.CONTRACT_SPEC_SECTION_NAME.equals(name)) { + specSections.add(payload); + } else if (WasmCustomSections.CONTRACT_ENV_META_SECTION_NAME.equals(name)) { + envMetaEntries.addAll(XdrStreams.parseScEnvMetaEntries(payload)); + } + } + + if (specSections.size() > 1) { + throw new InvalidWasmException( + "Invalid Wasm module: expected at most one '" + + WasmCustomSections.CONTRACT_SPEC_SECTION_NAME + + "' section."); + } + + List specEntries = + specSections.isEmpty() + ? Collections.emptyList() + : XdrStreams.parseScSpecEntries(specSections.get(0)); + + return new ContractInfo( + new ContractMeta(metaEntries), new ContractSpec(specEntries), envMetaEntries); + } + + /** + * Creates a {@link ContractInfo} from a contract Wasm file. + * + * @param path path to the contract Wasm file + * @throws IOException if the file cannot be read + * @throws InvalidWasmException if the Wasm module or any introspection section cannot be decoded + */ + public static ContractInfo fromWasmFile(Path path) throws IOException { + return fromWasm(Files.readAllBytes(path)); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/ContractMeta.java b/src/main/java/org/stellar/sdk/contract/ContractMeta.java new file mode 100644 index 000000000..9d27eba25 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/ContractMeta.java @@ -0,0 +1,245 @@ +package org.stellar.sdk.contract; + +import java.io.IOException; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.stellar.sdk.contract.exception.InvalidWasmException; +import org.stellar.sdk.xdr.SCMetaEntry; +import org.stellar.sdk.xdr.SCMetaKind; +import org.stellar.sdk.xdr.SCMetaV0; + +/** + * Represents SEP-0046 contract metadata and SEP-0047 contract interface discovery data. + * + *

Entries are stored in module order and exposed as an unmodifiable list. Construct with raw + * {@link SCMetaEntry} values, decode from contract Wasm bytes via {@link #fromWasm(byte[])}, or + * decode from a SEP-0046 XDR stream via {@link #fromXdrBytes(byte[])}. + * + * @see SEP-0046 + * @see SEP-0047 + */ +@Getter +@EqualsAndHashCode +@ToString +public final class ContractMeta implements Iterable { + private final List entries; + + public ContractMeta() { + this(Collections.emptyList()); + } + + public ContractMeta(List entries) { + if (entries == null) { + throw new IllegalArgumentException("entries must not be null"); + } + List copy = new ArrayList<>(entries.size()); + for (SCMetaEntry entry : entries) { + if (entry == null) { + throw new IllegalArgumentException("entries must not contain null elements"); + } + copy.add(entry); + } + this.entries = Collections.unmodifiableList(copy); + } + + /** + * Creates a {@link ContractMeta} from contract Wasm bytes by concatenating all {@code + * contractmetav0} custom sections in module order. + * + * @param wasm contract Wasm bytes + * @throws InvalidWasmException if the Wasm module or metadata section cannot be decoded + */ + public static ContractMeta fromWasm(byte[] wasm) { + List entries = new ArrayList<>(); + for (byte[] section : + WasmCustomSections.getCustomSections(wasm, WasmCustomSections.CONTRACT_META_SECTION_NAME)) { + entries.addAll(XdrStreams.parseScMetaEntries(section)); + } + return new ContractMeta(entries); + } + + /** + * Creates a {@link ContractMeta} from a contract Wasm file. + * + * @param path path to the contract Wasm file + * @throws IOException if the file cannot be read + * @throws InvalidWasmException if the Wasm module or metadata section cannot be decoded + */ + public static ContractMeta fromWasmFile(Path path) throws IOException { + return fromWasm(Files.readAllBytes(path)); + } + + /** + * Creates a {@link ContractMeta} from a SEP-0046 XDR stream of {@link SCMetaEntry} values. + * + * @param data XDR stream bytes + * @throws InvalidWasmException if the XDR stream cannot be decoded + */ + public static ContractMeta fromXdrBytes(byte[] data) { + return new ContractMeta(XdrStreams.parseScMetaEntries(data)); + } + + /** Serializes the entries as a SEP-0046 XDR stream. */ + public byte[] toXdrBytes() { + return XdrStreams.serializeScMetaEntries(entries); + } + + /** + * Returns {@code SC_META_V0} key/value pairs decoded as UTF-8, in entry order. + * + * @throws InvalidWasmException if a key or value is not valid UTF-8 + */ + public List> items() { + List> items = new ArrayList<>(); + for (SCMetaEntry entry : entries) { + if (entry.getDiscriminant() != SCMetaKind.SC_META_V0 || entry.getV0() == null) { + continue; + } + SCMetaV0 v0 = entry.getV0(); + items.add( + new AbstractMap.SimpleImmutableEntry<>( + decodeMetaString(v0.getKey().getBytes()), decodeMetaString(v0.getVal().getBytes()))); + } + return Collections.unmodifiableList(items); + } + + /** + * Returns the first {@code SC_META_V0} value for {@code key}. + * + * @throws InvalidWasmException if a key or value is not valid UTF-8 + */ + public Optional get(String key) { + if (key == null) { + return Optional.empty(); + } + for (Map.Entry item : items()) { + if (key.equals(item.getKey())) { + return Optional.of(item.getValue()); + } + } + return Optional.empty(); + } + + /** + * Returns all {@code SC_META_V0} values for {@code key} in entry order. + * + * @throws InvalidWasmException if a key or value is not valid UTF-8 + */ + public List getAll(String key) { + if (key == null) { + return Collections.emptyList(); + } + List values = new ArrayList<>(); + for (Map.Entry item : items()) { + if (key.equals(item.getKey())) { + values.add(item.getValue()); + } + } + return Collections.unmodifiableList(values); + } + + /** + * Returns SEP-0047 SEP identifiers declared via {@code sep} metadata entries. Values are returned + * in first-seen order with duplicates removed. Invalid identifiers are skipped. + */ + public List supportedSeps() { + return supportedSeps(false); + } + + /** + * Returns SEP-0047 SEP identifiers declared via {@code sep} metadata entries. + * + * @param strict when true, invalid identifiers cause an {@link IllegalArgumentException} rather + * than being skipped + * @throws IllegalArgumentException if {@code strict} is true and an identifier is invalid + * @throws InvalidWasmException if a key or value is not valid UTF-8 + */ + public List supportedSeps(boolean strict) { + Set seen = new HashSet<>(); + List supported = new ArrayList<>(); + for (String value : getAll("sep")) { + for (String rawPart : value.split(",", -1)) { + String sep = rawPart.trim(); + if (sep.isEmpty()) { + if (strict) { + throw new IllegalArgumentException("Invalid SEP identifier: empty value."); + } + continue; + } + if (!isAsciiDecimal(sep)) { + if (strict) { + throw new IllegalArgumentException("Invalid SEP identifier: '" + sep + "'."); + } + continue; + } + int sepNumber; + try { + sepNumber = Integer.parseInt(sep); + } catch (NumberFormatException e) { + if (strict) { + throw new IllegalArgumentException("Invalid SEP identifier: '" + sep + "'.", e); + } + continue; + } + if (seen.add(sepNumber)) { + supported.add(sepNumber); + } + } + } + return Collections.unmodifiableList(supported); + } + + /** Returns whether the contract declares support for {@code sep} via SEP-0047. */ + public boolean implementsSep(int sep) { + return supportedSeps().contains(sep); + } + + @Override + public Iterator iterator() { + return entries.iterator(); + } + + private static String decodeMetaString(byte[] data) { + CharsetDecoder decoder = + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return decoder.decode(java.nio.ByteBuffer.wrap(data)).toString(); + } catch (CharacterCodingException e) { + throw new InvalidWasmException("Contract meta contains a non-UTF-8 string.", e); + } + } + + private static boolean isAsciiDecimal(String value) { + if (value.isEmpty()) { + return false; + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c < '0' || c > '9') { + return false; + } + } + return true; + } +} diff --git a/src/main/java/org/stellar/sdk/contract/ContractSpec.java b/src/main/java/org/stellar/sdk/contract/ContractSpec.java new file mode 100644 index 000000000..760adcb1c --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/ContractSpec.java @@ -0,0 +1,282 @@ +package org.stellar.sdk.contract; + +import java.io.IOException; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.stellar.sdk.contract.exception.InvalidWasmException; +import org.stellar.sdk.xdr.SCSpecEntry; +import org.stellar.sdk.xdr.SCSpecEntryKind; +import org.stellar.sdk.xdr.SCSpecEventV0; +import org.stellar.sdk.xdr.SCSpecFunctionV0; +import org.stellar.sdk.xdr.SCSpecUDTEnumV0; +import org.stellar.sdk.xdr.SCSpecUDTErrorEnumV0; +import org.stellar.sdk.xdr.SCSpecUDTStructV0; +import org.stellar.sdk.xdr.SCSpecUDTUnionV0; +import org.stellar.sdk.xdr.SCSymbol; +import org.stellar.sdk.xdr.XdrString; + +/** + * Represents a SEP-0048 contract interface specification. + * + *

Entries are stored in module order and exposed as an unmodifiable list. Construct with raw + * {@link SCSpecEntry} values, decode from contract Wasm bytes via {@link #fromWasm(byte[])}, or + * decode from a SEP-0048 XDR stream via {@link #fromXdrBytes(byte[])}. + * + * @see SEP-0048 + */ +@Getter +@EqualsAndHashCode +@ToString +public final class ContractSpec implements Iterable { + private final List entries; + + public ContractSpec() { + this(Collections.emptyList()); + } + + public ContractSpec(List entries) { + if (entries == null) { + throw new IllegalArgumentException("entries must not be null"); + } + List copy = new ArrayList<>(entries.size()); + for (SCSpecEntry entry : entries) { + if (entry == null) { + throw new IllegalArgumentException("entries must not contain null elements"); + } + copy.add(entry); + } + this.entries = Collections.unmodifiableList(copy); + } + + /** + * Creates a {@link ContractSpec} from contract Wasm bytes. Returns an empty spec if the {@code + * contractspecv0} section is absent. + * + * @param wasm contract Wasm bytes + * @throws InvalidWasmException if the Wasm module or specification section cannot be decoded, or + * if multiple {@code contractspecv0} sections are present + */ + public static ContractSpec fromWasm(byte[] wasm) { + List sections = + WasmCustomSections.getCustomSections(wasm, WasmCustomSections.CONTRACT_SPEC_SECTION_NAME); + if (sections.size() > 1) { + throw new InvalidWasmException( + "Invalid Wasm module: expected at most one '" + + WasmCustomSections.CONTRACT_SPEC_SECTION_NAME + + "' section."); + } + if (sections.isEmpty()) { + return new ContractSpec(); + } + return new ContractSpec(XdrStreams.parseScSpecEntries(sections.get(0))); + } + + /** + * Creates a {@link ContractSpec} from a contract Wasm file. + * + * @param path path to the contract Wasm file + * @throws IOException if the file cannot be read + * @throws InvalidWasmException if the Wasm module or specification section cannot be decoded + */ + public static ContractSpec fromWasmFile(Path path) throws IOException { + return fromWasm(Files.readAllBytes(path)); + } + + /** + * Creates a {@link ContractSpec} from a SEP-0048 XDR stream of {@link SCSpecEntry} values. + * + * @param data XDR stream bytes + * @throws InvalidWasmException if the XDR stream cannot be decoded + */ + public static ContractSpec fromXdrBytes(byte[] data) { + return new ContractSpec(XdrStreams.parseScSpecEntries(data)); + } + + /** Serializes the entries as a SEP-0048 XDR stream. */ + public byte[] toXdrBytes() { + return XdrStreams.serializeScSpecEntries(entries); + } + + public List getFunctions() { + List functions = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0 + && entry.getFunctionV0() != null) { + functions.add(entry.getFunctionV0()); + } + } + return Collections.unmodifiableList(functions); + } + + public List getEvents() { + List events = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_EVENT_V0 + && entry.getEventV0() != null) { + events.add(entry.getEventV0()); + } + } + return Collections.unmodifiableList(events); + } + + public List getStructs() { + List structs = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_STRUCT_V0 + && entry.getUdtStructV0() != null) { + structs.add(entry.getUdtStructV0()); + } + } + return Collections.unmodifiableList(structs); + } + + public List getUnions() { + List unions = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_UNION_V0 + && entry.getUdtUnionV0() != null) { + unions.add(entry.getUdtUnionV0()); + } + } + return Collections.unmodifiableList(unions); + } + + public List getEnums() { + List enums = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ENUM_V0 + && entry.getUdtEnumV0() != null) { + enums.add(entry.getUdtEnumV0()); + } + } + return Collections.unmodifiableList(enums); + } + + public List getErrorEnums() { + List errorEnums = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0 + && entry.getUdtErrorEnumV0() != null) { + errorEnums.add(entry.getUdtErrorEnumV0()); + } + } + return Collections.unmodifiableList(errorEnums); + } + + /** + * Returns the function with the given name, if present. + * + * @throws InvalidWasmException if a function name is not valid UTF-8 + */ + public Optional getFunction(String name) { + if (name == null) { + return Optional.empty(); + } + for (SCSpecFunctionV0 function : getFunctions()) { + if (name.equals(decodeSymbol(function.getName()))) { + return Optional.of(function); + } + } + return Optional.empty(); + } + + /** + * Returns the event with the given name, if present. + * + * @throws InvalidWasmException if an event name is not valid UTF-8 + */ + public Optional getEvent(String name) { + if (name == null) { + return Optional.empty(); + } + for (SCSpecEventV0 event : getEvents()) { + if (name.equals(decodeSymbol(event.getName()))) { + return Optional.of(event); + } + } + return Optional.empty(); + } + + /** + * Returns the user-defined type entry (struct, union, enum, or error enum) with the given name, + * if present. + * + * @throws InvalidWasmException if a type name is not valid UTF-8 + */ + public Optional getUdt(String name) { + if (name == null) { + return Optional.empty(); + } + for (SCSpecEntry entry : entries) { + String udtName = getUdtName(entry); + if (udtName != null && udtName.equals(name)) { + return Optional.of(entry); + } + } + return Optional.empty(); + } + + @Override + public Iterator iterator() { + return entries.iterator(); + } + + private static String getUdtName(SCSpecEntry entry) { + switch (entry.getDiscriminant()) { + case SC_SPEC_ENTRY_UDT_STRUCT_V0: + return entry.getUdtStructV0() != null + ? decodeString(entry.getUdtStructV0().getName()) + : null; + case SC_SPEC_ENTRY_UDT_UNION_V0: + return entry.getUdtUnionV0() != null ? decodeString(entry.getUdtUnionV0().getName()) : null; + case SC_SPEC_ENTRY_UDT_ENUM_V0: + return entry.getUdtEnumV0() != null ? decodeString(entry.getUdtEnumV0().getName()) : null; + case SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0: + return entry.getUdtErrorEnumV0() != null + ? decodeString(entry.getUdtErrorEnumV0().getName()) + : null; + default: + return null; + } + } + + private static String decodeSymbol(SCSymbol symbol) { + if (symbol == null || symbol.getSCSymbol() == null) { + throw new InvalidWasmException("Contract spec contains a null symbol."); + } + return decodeUtf8Strict(symbol.getSCSymbol().getBytes(), "symbol"); + } + + private static String decodeString(XdrString value) { + if (value == null) { + throw new InvalidWasmException("Contract spec contains a null string."); + } + return decodeUtf8Strict(value.getBytes(), "string"); + } + + private static String decodeUtf8Strict(byte[] data, String kind) { + CharsetDecoder decoder = + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return decoder.decode(java.nio.ByteBuffer.wrap(data)).toString(); + } catch (CharacterCodingException e) { + throw new InvalidWasmException("Contract spec contains a non-UTF-8 " + kind + ".", e); + } + } +} diff --git a/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java b/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java new file mode 100644 index 000000000..50c0473cc --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java @@ -0,0 +1,149 @@ +package org.stellar.sdk.contract; + +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.stellar.sdk.contract.exception.InvalidWasmException; + +/** + * Parses Wasm modules and extracts custom section payloads. Intended for SEP-0046/0047/0048 + * contract introspection. + * + *

Implements just enough of the Wasm binary format to walk top-level sections, validate the + * header, and return the raw payload bytes of custom sections by name. + */ +final class WasmCustomSections { + static final String CONTRACT_META_SECTION_NAME = "contractmetav0"; + static final String CONTRACT_SPEC_SECTION_NAME = "contractspecv0"; + static final String CONTRACT_ENV_META_SECTION_NAME = "contractenvmetav0"; + + private static final byte[] WASM_MAGIC = {0x00, 0x61, 0x73, 0x6d}; + private static final byte[] WASM_VERSION_1 = {0x01, 0x00, 0x00, 0x00}; + private static final int CUSTOM_SECTION_ID = 0; + private static final int MAX_LEB128_U32_BYTES = 5; + + private WasmCustomSections() {} + + /** + * Returns all custom section name/payload pairs in module order. Each payload is a fresh byte + * array copied out of the input. + */ + static List> getCustomSections(byte[] wasm) { + if (wasm == null) { + throw new IllegalArgumentException("wasm must not be null"); + } + if (wasm.length < 8) { + throw new InvalidWasmException("Invalid Wasm module: header is too short."); + } + if (!matches(wasm, 0, WASM_MAGIC)) { + throw new InvalidWasmException("Invalid Wasm module: bad magic header."); + } + if (!matches(wasm, 4, WASM_VERSION_1)) { + throw new InvalidWasmException("Invalid Wasm module: unsupported version."); + } + + List> sections = new ArrayList<>(); + int offset = 8; + int wasmLen = wasm.length; + while (offset < wasmLen) { + int sectionId = wasm[offset] & 0xff; + offset += 1; + + long[] sizeRead = readU32Leb128(wasm, offset, wasmLen); + long sectionSize = sizeRead[0]; + offset = (int) sizeRead[1]; + + long sectionEndLong = (long) offset + sectionSize; + if (sectionEndLong > wasmLen) { + throw new InvalidWasmException("Invalid Wasm module: section extends past EOF."); + } + int sectionEnd = (int) sectionEndLong; + + if (sectionId == CUSTOM_SECTION_ID) { + long[] nameLenRead = readU32Leb128(wasm, offset, sectionEnd); + long nameLen = nameLenRead[0]; + int nameStart = (int) nameLenRead[1]; + long nameEndLong = (long) nameStart + nameLen; + if (nameEndLong > sectionEnd) { + throw new InvalidWasmException( + "Invalid Wasm custom section: name extends past section end."); + } + int nameEnd = (int) nameEndLong; + String name = decodeUtf8Strict(wasm, nameStart, nameEnd - nameStart); + byte[] payload = Arrays.copyOfRange(wasm, nameEnd, sectionEnd); + sections.add(new AbstractMap.SimpleImmutableEntry<>(name, payload)); + } + + offset = sectionEnd; + } + return sections; + } + + /** Returns all payloads for custom sections matching {@code name} in module order. */ + static List getCustomSections(byte[] wasm, String name) { + if (name == null) { + throw new IllegalArgumentException("name must not be null"); + } + List result = new ArrayList<>(); + for (Map.Entry entry : getCustomSections(wasm)) { + if (name.equals(entry.getKey())) { + result.add(entry.getValue()); + } + } + return result; + } + + private static boolean matches(byte[] data, int offset, byte[] expected) { + for (int i = 0; i < expected.length; i++) { + if (data[offset + i] != expected[i]) { + return false; + } + } + return true; + } + + /** + * Reads an unsigned LEB128 u32 from {@code data} starting at {@code offset}. Returns a two-long + * array {@code [value, nextOffset]}. {@code limit} is exclusive — reads must not pass it. + */ + private static long[] readU32Leb128(byte[] data, int offset, int limit) { + long result = 0; + int shift = 0; + int pos = offset; + for (int i = 0; i < MAX_LEB128_U32_BYTES; i++) { + if (pos >= limit) { + throw new InvalidWasmException("Invalid Wasm module: truncated LEB128 value."); + } + int b = data[pos] & 0xff; + pos += 1; + result |= ((long) (b & 0x7f)) << shift; + if ((b & 0x80) == 0) { + if (result > 0xffffffffL) { + throw new InvalidWasmException("Invalid Wasm module: LEB128 value exceeds u32."); + } + return new long[] {result, pos}; + } + shift += 7; + } + throw new InvalidWasmException("Invalid Wasm module: LEB128 value is too long."); + } + + private static String decodeUtf8Strict(byte[] data, int offset, int length) { + CharsetDecoder decoder = + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return decoder.decode(java.nio.ByteBuffer.wrap(data, offset, length)).toString(); + } catch (CharacterCodingException e) { + throw new InvalidWasmException("Invalid Wasm custom section: name is not UTF-8.", e); + } + } +} diff --git a/src/main/java/org/stellar/sdk/contract/XdrStreams.java b/src/main/java/org/stellar/sdk/contract/XdrStreams.java new file mode 100644 index 000000000..11f0c3e02 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/XdrStreams.java @@ -0,0 +1,108 @@ +package org.stellar.sdk.contract; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.stellar.sdk.contract.exception.InvalidWasmException; +import org.stellar.sdk.xdr.SCEnvMetaEntry; +import org.stellar.sdk.xdr.SCMetaEntry; +import org.stellar.sdk.xdr.SCSpecEntry; +import org.stellar.sdk.xdr.XdrDataInputStream; +import org.stellar.sdk.xdr.XdrDataOutputStream; + +/** + * Parses and serializes unframed XDR streams of contract introspection entries (SEP-0046/0047 meta, + * SEP-0048 spec, and contract environment meta). + */ +final class XdrStreams { + private XdrStreams() {} + + @FunctionalInterface + private interface XdrDecoder { + T decode(XdrDataInputStream stream) throws IOException; + } + + @FunctionalInterface + private interface XdrEncoder { + void encode(T entry, XdrDataOutputStream stream) throws IOException; + } + + static List parseScMetaEntries(byte[] data) { + return parseStream(data, SCMetaEntry::decode, "SCMetaEntry"); + } + + static List parseScSpecEntries(byte[] data) { + return parseStream(data, SCSpecEntry::decode, "SCSpecEntry"); + } + + static List parseScEnvMetaEntries(byte[] data) { + return parseStream(data, SCEnvMetaEntry::decode, "SCEnvMetaEntry"); + } + + static byte[] serializeScMetaEntries(Iterable entries) { + return serializeStream(entries, (entry, stream) -> entry.encode(stream)); + } + + static byte[] serializeScSpecEntries(Iterable entries) { + return serializeStream(entries, (entry, stream) -> entry.encode(stream)); + } + + static byte[] serializeScEnvMetaEntries(Iterable entries) { + return serializeStream(entries, (entry, stream) -> entry.encode(stream)); + } + + private static List parseStream(byte[] data, XdrDecoder decoder, String entryName) { + if (data == null) { + throw new IllegalArgumentException("data must not be null"); + } + ByteArrayInputStream byteStream = new ByteArrayInputStream(data); + XdrDataInputStream xdrStream = new XdrDataInputStream(byteStream); + xdrStream.setMaxInputLen(data.length); + + List entries = new ArrayList<>(); + int previousAvailable = data.length; + while (previousAvailable > 0) { + T entry; + try { + entry = decoder.decode(xdrStream); + } catch (IOException e) { + throw new InvalidWasmException("Invalid XDR stream for " + entryName + ".", e); + } catch (IllegalArgumentException e) { + throw new InvalidWasmException("Invalid XDR stream for " + entryName + ".", e); + } + int currentAvailable = byteStream.available(); + if (currentAvailable >= previousAvailable) { + throw new InvalidWasmException( + "Invalid XDR stream for " + entryName + ": decoder made no progress."); + } + previousAvailable = currentAvailable; + entries.add(entry); + } + + if (byteStream.available() != 0) { + throw new InvalidWasmException("Invalid XDR stream for " + entryName + ": trailing bytes."); + } + return entries; + } + + private static byte[] serializeStream(Iterable entries, XdrEncoder encoder) { + if (entries == null) { + throw new IllegalArgumentException("entries must not be null"); + } + ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); + XdrDataOutputStream xdrStream = new XdrDataOutputStream(byteStream); + try { + for (T entry : entries) { + if (entry == null) { + throw new IllegalArgumentException("entries must not contain null elements"); + } + encoder.encode(entry, xdrStream); + } + } catch (IOException e) { + throw new InvalidWasmException("Failed to serialize XDR stream.", e); + } + return byteStream.toByteArray(); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/ContractCodeNotFoundException.java b/src/main/java/org/stellar/sdk/contract/exception/ContractCodeNotFoundException.java new file mode 100644 index 000000000..28578c49f --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/ContractCodeNotFoundException.java @@ -0,0 +1,20 @@ +package org.stellar.sdk.contract.exception; + +/** + * Raised when a contract code ledger entry cannot be found on the network. The entry may have been + * archived; restoring the contract code footprint may be required. + */ +public class ContractCodeNotFoundException extends ContractIntrospectionException { + private final byte[] wasmHash; + + public ContractCodeNotFoundException(byte[] wasmHash) { + super( + "Contract code not found or archived. The contract code footprint may need to be restored."); + this.wasmHash = wasmHash == null ? null : wasmHash.clone(); + } + + /** Returns a defensive copy of the Wasm hash that was looked up. */ + public byte[] getWasmHash() { + return wasmHash == null ? null : wasmHash.clone(); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/ContractInstanceNotFoundException.java b/src/main/java/org/stellar/sdk/contract/exception/ContractInstanceNotFoundException.java new file mode 100644 index 000000000..eeb3b0224 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/ContractInstanceNotFoundException.java @@ -0,0 +1,14 @@ +package org.stellar.sdk.contract.exception; + +import lombok.Getter; + +/** Raised when a contract instance ledger entry cannot be found on the network. */ +@Getter +public class ContractInstanceNotFoundException extends ContractIntrospectionException { + private final String contractId; + + public ContractInstanceNotFoundException(String contractId) { + super("Contract instance not found, contractId: " + contractId); + this.contractId = contractId; + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/ContractIntrospectionException.java b/src/main/java/org/stellar/sdk/contract/exception/ContractIntrospectionException.java new file mode 100644 index 000000000..d6ffe31c5 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/ContractIntrospectionException.java @@ -0,0 +1,14 @@ +package org.stellar.sdk.contract.exception; + +import org.stellar.sdk.exception.SdkException; + +/** Base class for exceptions raised by contract introspection APIs. */ +public class ContractIntrospectionException extends SdkException { + public ContractIntrospectionException(String message) { + super(message); + } + + public ContractIntrospectionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/ContractWasmRetrievalException.java b/src/main/java/org/stellar/sdk/contract/exception/ContractWasmRetrievalException.java new file mode 100644 index 000000000..07abdd138 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/ContractWasmRetrievalException.java @@ -0,0 +1,12 @@ +package org.stellar.sdk.contract.exception; + +/** Raised when a contract Wasm cannot be retrieved due to unexpected RPC response data. */ +public class ContractWasmRetrievalException extends ContractIntrospectionException { + public ContractWasmRetrievalException(String message) { + super(message); + } + + public ContractWasmRetrievalException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/InvalidWasmException.java b/src/main/java/org/stellar/sdk/contract/exception/InvalidWasmException.java new file mode 100644 index 000000000..c8f4659c3 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/InvalidWasmException.java @@ -0,0 +1,12 @@ +package org.stellar.sdk.contract.exception; + +/** Raised when a Wasm module or XDR stream cannot be decoded. */ +public class InvalidWasmException extends ContractIntrospectionException { + public InvalidWasmException(String message) { + super(message); + } + + public InvalidWasmException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/StellarAssetContractHasNoWasmException.java b/src/main/java/org/stellar/sdk/contract/exception/StellarAssetContractHasNoWasmException.java new file mode 100644 index 000000000..d72e2a831 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/StellarAssetContractHasNoWasmException.java @@ -0,0 +1,17 @@ +package org.stellar.sdk.contract.exception; + +import lombok.Getter; + +/** + * Raised when the requested contract is a Stellar Asset Contract, which does not have an associated + * Wasm module. + */ +@Getter +public class StellarAssetContractHasNoWasmException extends ContractIntrospectionException { + private final String contractId; + + public StellarAssetContractHasNoWasmException(String contractId) { + super("Contract is a Stellar Asset Contract and has no Wasm module, contractId: " + contractId); + this.contractId = contractId; + } +} diff --git a/src/main/java/org/stellar/sdk/contract/exception/package-info.java b/src/main/java/org/stellar/sdk/contract/exception/package-info.java index 2ec24f728..0219008ba 100644 --- a/src/main/java/org/stellar/sdk/contract/exception/package-info.java +++ b/src/main/java/org/stellar/sdk/contract/exception/package-info.java @@ -1,13 +1,21 @@ /** - * Exceptions specific to smart contract transaction assembly and execution. + * Exceptions specific to smart contract APIs. * - *

These exceptions are thrown by {@link org.stellar.sdk.contract.AssembledTransaction} during - * the lifecycle of a contract invocation, including simulation failures, signing issues, submission - * errors, and expired contract state. + *

This package contains two families of exceptions: * - *

All exceptions in this package extend {@link - * org.stellar.sdk.contract.exception.AssembledTransactionException}. + *

* * @see org.stellar.sdk.contract.AssembledTransaction + * @see org.stellar.sdk.contract.ContractInfo */ package org.stellar.sdk.contract.exception; diff --git a/src/test/kotlin/org/stellar/sdk/contract/ContractInfoTest.kt b/src/test/kotlin/org/stellar/sdk/contract/ContractInfoTest.kt new file mode 100644 index 000000000..5f0322214 --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/ContractInfoTest.kt @@ -0,0 +1,134 @@ +package org.stellar.sdk.contract + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import java.io.ByteArrayOutputStream +import java.util.Optional +import org.stellar.sdk.contract.exception.InvalidWasmException +import org.stellar.sdk.xdr.SCEnvMetaEntry +import org.stellar.sdk.xdr.SCEnvMetaKind +import org.stellar.sdk.xdr.SCMetaEntry +import org.stellar.sdk.xdr.SCMetaKind +import org.stellar.sdk.xdr.SCMetaV0 +import org.stellar.sdk.xdr.SCSpecEntry +import org.stellar.sdk.xdr.SCSpecEntryKind +import org.stellar.sdk.xdr.SCSpecFunctionInputV0 +import org.stellar.sdk.xdr.SCSpecFunctionV0 +import org.stellar.sdk.xdr.SCSpecTypeDef +import org.stellar.sdk.xdr.SCSymbol +import org.stellar.sdk.xdr.Uint32 +import org.stellar.sdk.xdr.XdrString +import org.stellar.sdk.xdr.XdrUnsignedInteger + +private fun meta(key: String, value: String): SCMetaEntry { + val v0 = + SCMetaV0().apply { + this.key = XdrString(key.toByteArray(Charsets.UTF_8)) + this.`val` = XdrString(value.toByteArray(Charsets.UTF_8)) + } + return SCMetaEntry().apply { + discriminant = SCMetaKind.SC_META_V0 + this.v0 = v0 + } +} + +private fun functionEntry(name: String): SCSpecEntry { + val fn = + SCSpecFunctionV0().apply { + doc = XdrString(ByteArray(0)) + this.name = SCSymbol().apply { scSymbol = XdrString(name.toByteArray(Charsets.UTF_8)) } + inputs = arrayOf() + outputs = arrayOf() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0 + functionV0 = fn + } +} + +private fun envMetaEntry(protocol: Int, preRelease: Int): SCEnvMetaEntry { + val version = + SCEnvMetaEntry.SCEnvMetaEntryInterfaceVersion().apply { + this.protocol = Uint32().apply { uint32 = XdrUnsignedInteger(protocol.toLong()) } + this.preRelease = Uint32().apply { uint32 = XdrUnsignedInteger(preRelease.toLong()) } + } + return SCEnvMetaEntry().apply { + discriminant = SCEnvMetaKind.SC_ENV_META_KIND_INTERFACE_VERSION + interfaceVersion = version + } +} + +class ContractInfoTest : + FunSpec({ + test("single-pass parses all sections") { + val metaXdr = XdrStreams.serializeScMetaEntries(listOf(meta("k", "v"))) + val specXdr = XdrStreams.serializeScSpecEntries(listOf(functionEntry("fn"))) + val envXdr = XdrStreams.serializeScEnvMetaEntries(listOf(envMetaEntry(22, 0))) + + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractmetav0", metaXdr)) + write(WasmTestSupport.buildCustomSection("contractenvmetav0", envXdr)) + write(WasmTestSupport.buildCustomSection("contractspecv0", specXdr)) + } + .toByteArray() + + val info = ContractInfo.fromWasm(wasm) + info.meta.get("k") shouldBe Optional.of("v") + info.spec.functions.size shouldBe 1 + info.envMeta.size shouldBe 1 + info.envMeta[0].interfaceVersion shouldNotBe null + } + + test("empty envMeta when absent") { + val metaXdr = XdrStreams.serializeScMetaEntries(listOf(meta("k", "v"))) + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractmetav0", metaXdr)) + } + .toByteArray() + ContractInfo.fromWasm(wasm).envMeta.isEmpty() shouldBe true + } + + test("multiple envMeta sections merged in order") { + val env1 = XdrStreams.serializeScEnvMetaEntries(listOf(envMetaEntry(22, 0))) + val env2 = XdrStreams.serializeScEnvMetaEntries(listOf(envMetaEntry(23, 1))) + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractenvmetav0", env1)) + write(WasmTestSupport.buildCustomSection("contractenvmetav0", env2)) + } + .toByteArray() + val info = ContractInfo.fromWasm(wasm) + info.envMeta.size shouldBe 2 + info.envMeta[0].interfaceVersion.protocol.uint32.number shouldBe 22L + info.envMeta[1].interfaceVersion.protocol.uint32.number shouldBe 23L + } + + test("constructor rejects null envMeta elements") { + shouldThrow { + ContractInfo(ContractMeta(), ContractSpec(), listOf(envMetaEntry(22, 0), null)) + } + } + + test("multiple spec sections are rejected") { + val specXdr = XdrStreams.serializeScSpecEntries(listOf(functionEntry("fn"))) + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractspecv0", specXdr)) + write(WasmTestSupport.buildCustomSection("contractspecv0", specXdr)) + } + .toByteArray() + shouldThrow { ContractInfo.fromWasm(wasm) } + } + }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt b/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt new file mode 100644 index 000000000..d152de7d7 --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt @@ -0,0 +1,130 @@ +package org.stellar.sdk.contract + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.io.ByteArrayOutputStream +import java.util.Optional +import org.stellar.sdk.contract.exception.InvalidWasmException +import org.stellar.sdk.xdr.SCMetaEntry +import org.stellar.sdk.xdr.SCMetaKind +import org.stellar.sdk.xdr.SCMetaV0 +import org.stellar.sdk.xdr.XdrString + +private fun metaBytes(key: ByteArray, value: ByteArray): SCMetaEntry { + val v0 = + SCMetaV0().apply { + this.key = XdrString(key) + this.`val` = XdrString(value) + } + return SCMetaEntry().apply { + discriminant = SCMetaKind.SC_META_V0 + this.v0 = v0 + } +} + +private fun meta(key: String, value: String): SCMetaEntry = + metaBytes(key.toByteArray(Charsets.UTF_8), value.toByteArray(Charsets.UTF_8)) + +private fun wasmWith(vararg entries: SCMetaEntry): ByteArray { + val xdr = XdrStreams.serializeScMetaEntries(entries.toList()) + return ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractmetav0", xdr)) + } + .toByteArray() +} + +class ContractMetaTest : + FunSpec({ + test("merges multiple sections in order") { + val xdr1 = XdrStreams.serializeScMetaEntries(listOf(meta("a", "1"))) + val xdr2 = XdrStreams.serializeScMetaEntries(listOf(meta("b", "2"))) + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractmetav0", xdr1)) + write(WasmTestSupport.buildCustomSection("contractmetav0", xdr2)) + } + .toByteArray() + val meta = ContractMeta.fromWasm(wasm) + meta.entries.size shouldBe 2 + meta.get("a") shouldBe Optional.of("1") + meta.get("b") shouldBe Optional.of("2") + } + + test("items, get, getAll") { + val meta = ContractMeta.fromWasm(wasmWith(meta("a", "1"), meta("a", "2"), meta("b", "3"))) + meta.items().size shouldBe 3 + meta.items()[0].key shouldBe "a" + meta.items()[0].value shouldBe "1" + meta.get("a") shouldBe Optional.of("1") + meta.getAll("a") shouldBe listOf("1", "2") + meta.get("b") shouldBe Optional.of("3") + meta.get("missing") shouldBe Optional.empty() + } + + test("supportedSeps parses comma-separated") { + val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41,40"))) + meta.supportedSeps() shouldBe listOf(41, 40) + meta.implementsSep(40) shouldBe true + meta.implementsSep(42) shouldBe false + } + + test("supportedSeps merges multiple entries") { + val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41"), meta("sep", "40,46"))) + meta.supportedSeps() shouldBe listOf(41, 40, 46) + } + + test("supportedSeps accepts leading zeros and deduplicates") { + val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "041"), meta("sep", "41,41,40"))) + meta.supportedSeps() shouldBe listOf(41, 40) + } + + test("supportedSeps skips invalid by default") { + val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41, ,abc,40"))) + meta.supportedSeps() shouldBe listOf(41, 40) + } + + test("supportedSeps rejects invalid in strict mode") { + val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41,abc"))) + shouldThrow { meta.supportedSeps(true) } + } + + test("supportedSeps rejects empty in strict mode") { + val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41,"))) + shouldThrow { meta.supportedSeps(true) } + } + + test("non-UTF-8 value rejects on access, not on construction") { + val entry = + metaBytes("k".toByteArray(Charsets.UTF_8), byteArrayOf(0xff.toByte(), 0xff.toByte())) + val meta = ContractMeta.fromWasm(wasmWith(entry)) + shouldThrow { meta.items() } + shouldThrow { meta.get("k") } + } + + test("no-arg constructor produces empty entries") { + val meta = ContractMeta() + meta.entries.isEmpty() shouldBe true + meta.supportedSeps().isEmpty() shouldBe true + meta.get("anything") shouldBe Optional.empty() + } + + test("XDR bytes round trip") { + val original = ContractMeta(listOf(meta("a", "1"), meta("sep", "41"))) + val restored = ContractMeta.fromXdrBytes(original.toXdrBytes()) + restored shouldBe original + } + + test("entries list is unmodifiable") { + val meta = ContractMeta(listOf(meta("k", "v"))) + shouldThrow { meta.entries.clear() } + } + + test("constructor rejects null elements") { + shouldThrow { ContractMeta(listOf(meta("k", "v"), null)) } + } + }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt b/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt new file mode 100644 index 000000000..84645b208 --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt @@ -0,0 +1,222 @@ +package org.stellar.sdk.contract + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.io.ByteArrayOutputStream +import org.stellar.sdk.contract.exception.InvalidWasmException +import org.stellar.sdk.xdr.SCSpecEntry +import org.stellar.sdk.xdr.SCSpecEntryKind +import org.stellar.sdk.xdr.SCSpecEventDataFormat +import org.stellar.sdk.xdr.SCSpecEventParamV0 +import org.stellar.sdk.xdr.SCSpecEventV0 +import org.stellar.sdk.xdr.SCSpecFunctionInputV0 +import org.stellar.sdk.xdr.SCSpecFunctionV0 +import org.stellar.sdk.xdr.SCSpecTypeDef +import org.stellar.sdk.xdr.SCSpecUDTEnumCaseV0 +import org.stellar.sdk.xdr.SCSpecUDTEnumV0 +import org.stellar.sdk.xdr.SCSpecUDTErrorEnumCaseV0 +import org.stellar.sdk.xdr.SCSpecUDTErrorEnumV0 +import org.stellar.sdk.xdr.SCSpecUDTStructFieldV0 +import org.stellar.sdk.xdr.SCSpecUDTStructV0 +import org.stellar.sdk.xdr.SCSpecUDTUnionCaseV0 +import org.stellar.sdk.xdr.SCSpecUDTUnionV0 +import org.stellar.sdk.xdr.SCSymbol +import org.stellar.sdk.xdr.XdrString + +private fun symbol(name: String): SCSymbol = + SCSymbol().apply { scSymbol = XdrString(name.toByteArray(Charsets.UTF_8)) } + +private fun symbolBytes(bytes: ByteArray): SCSymbol = + SCSymbol().apply { scSymbol = XdrString(bytes) } + +private fun functionEntry(name: String): SCSpecEntry { + val fn = + SCSpecFunctionV0().apply { + doc = XdrString(ByteArray(0)) + this.name = symbol(name) + inputs = arrayOf() + outputs = arrayOf() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0 + functionV0 = fn + } +} + +private fun eventEntry(name: String): SCSpecEntry { + val event = + SCSpecEventV0().apply { + doc = XdrString(ByteArray(0)) + lib = XdrString(ByteArray(0)) + this.name = symbol(name) + prefixTopics = arrayOf() + params = arrayOf() + dataFormat = SCSpecEventDataFormat.values().first() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_EVENT_V0 + eventV0 = event + } +} + +private fun structEntry(name: String): SCSpecEntry { + val udt = + SCSpecUDTStructV0().apply { + doc = XdrString(ByteArray(0)) + lib = XdrString(ByteArray(0)) + this.name = XdrString(name.toByteArray(Charsets.UTF_8)) + fields = arrayOf() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_UDT_STRUCT_V0 + udtStructV0 = udt + } +} + +private fun unionEntry(name: String): SCSpecEntry { + val udt = + SCSpecUDTUnionV0().apply { + doc = XdrString(ByteArray(0)) + lib = XdrString(ByteArray(0)) + this.name = XdrString(name.toByteArray(Charsets.UTF_8)) + cases = arrayOf() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_UDT_UNION_V0 + udtUnionV0 = udt + } +} + +private fun enumEntry(name: String): SCSpecEntry { + val udt = + SCSpecUDTEnumV0().apply { + doc = XdrString(ByteArray(0)) + lib = XdrString(ByteArray(0)) + this.name = XdrString(name.toByteArray(Charsets.UTF_8)) + cases = arrayOf() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ENUM_V0 + udtEnumV0 = udt + } +} + +private fun errorEnumEntry(name: String): SCSpecEntry { + val udt = + SCSpecUDTErrorEnumV0().apply { + doc = XdrString(ByteArray(0)) + lib = XdrString(ByteArray(0)) + this.name = XdrString(name.toByteArray(Charsets.UTF_8)) + cases = arrayOf() + } + return SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0 + udtErrorEnumV0 = udt + } +} + +private fun wasmWith(vararg entries: SCSpecEntry): ByteArray { + val xdr = XdrStreams.serializeScSpecEntries(entries.toList()) + return ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractspecv0", xdr)) + } + .toByteArray() +} + +class ContractSpecTest : + FunSpec({ + test("empty when section absent") { + ContractSpec.fromWasm(WasmTestSupport.WASM_HEADER).entries.isEmpty() shouldBe true + } + + test("rejects multiple spec sections") { + val xdr = XdrStreams.serializeScSpecEntries(listOf(functionEntry("a"))) + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractspecv0", xdr)) + write(WasmTestSupport.buildCustomSection("contractspecv0", xdr)) + } + .toByteArray() + shouldThrow { ContractSpec.fromWasm(wasm) } + } + + test("classifies all entry kinds") { + val spec = + ContractSpec.fromWasm( + wasmWith( + functionEntry("fn"), + eventEntry("evt"), + structEntry("S"), + unionEntry("U"), + enumEntry("E"), + errorEnumEntry("Err"), + ) + ) + spec.entries.size shouldBe 6 + spec.functions.size shouldBe 1 + spec.events.size shouldBe 1 + spec.structs.size shouldBe 1 + spec.unions.size shouldBe 1 + spec.enums.size shouldBe 1 + spec.errorEnums.size shouldBe 1 + } + + test("get function by name") { + val spec = + ContractSpec.fromWasm(wasmWith(functionEntry("transfer"), functionEntry("balance"))) + val fn = spec.getFunction("transfer") + fn.isPresent shouldBe true + String(fn.get().name.scSymbol.bytes, Charsets.UTF_8) shouldBe "transfer" + spec.getFunction("unknown").isPresent shouldBe false + } + + test("get event by name") { + val spec = ContractSpec.fromWasm(wasmWith(eventEntry("Transfer"))) + spec.getEvent("Transfer").isPresent shouldBe true + spec.getEvent("missing").isPresent shouldBe false + } + + test("get UDT by name") { + val spec = + ContractSpec.fromWasm( + wasmWith(structEntry("S1"), unionEntry("U1"), enumEntry("E1"), errorEnumEntry("Err1")) + ) + spec.getUdt("S1").isPresent shouldBe true + spec.getUdt("U1").isPresent shouldBe true + spec.getUdt("E1").isPresent shouldBe true + spec.getUdt("Err1").isPresent shouldBe true + spec.getUdt("missing").isPresent shouldBe false + } + + test("non-UTF-8 function name rejected by lookup") { + val fn = + SCSpecFunctionV0().apply { + doc = XdrString(ByteArray(0)) + name = symbolBytes(byteArrayOf(0xff.toByte())) + inputs = arrayOf() + outputs = arrayOf() + } + val entry = + SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0 + functionV0 = fn + } + val spec = ContractSpec.fromWasm(wasmWith(entry)) + shouldThrow { spec.getFunction("anything") } + } + + test("XDR bytes round trip") { + val original = ContractSpec(listOf(functionEntry("a"), eventEntry("b"))) + val restored = ContractSpec.fromXdrBytes(original.toXdrBytes()) + restored shouldBe original + } + + test("constructor rejects null elements") { + shouldThrow { ContractSpec(listOf(functionEntry("a"), null)) } + } + }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt new file mode 100644 index 000000000..14600a73d --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt @@ -0,0 +1,340 @@ +package org.stellar.sdk.contract + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.io.ByteArrayOutputStream +import java.security.MessageDigest +import java.util.Optional +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.stellar.sdk.Address +import org.stellar.sdk.SorobanServer +import org.stellar.sdk.contract.exception.ContractCodeNotFoundException +import org.stellar.sdk.contract.exception.ContractInstanceNotFoundException +import org.stellar.sdk.contract.exception.ContractWasmRetrievalException +import org.stellar.sdk.contract.exception.StellarAssetContractHasNoWasmException +import org.stellar.sdk.scval.Scv +import org.stellar.sdk.xdr.ContractCodeEntry +import org.stellar.sdk.xdr.ContractDataDurability +import org.stellar.sdk.xdr.ContractDataEntry +import org.stellar.sdk.xdr.ContractExecutable +import org.stellar.sdk.xdr.ContractExecutableType +import org.stellar.sdk.xdr.ExtensionPoint +import org.stellar.sdk.xdr.Hash +import org.stellar.sdk.xdr.LedgerEntry +import org.stellar.sdk.xdr.LedgerEntryType +import org.stellar.sdk.xdr.SCContractInstance +import org.stellar.sdk.xdr.SCMap +import org.stellar.sdk.xdr.SCMapEntry +import org.stellar.sdk.xdr.SCMetaEntry +import org.stellar.sdk.xdr.SCMetaKind +import org.stellar.sdk.xdr.SCMetaV0 +import org.stellar.sdk.xdr.SCVal +import org.stellar.sdk.xdr.SCValType +import org.stellar.sdk.xdr.XdrString + +private const val CONTRACT_ID = "CBQHNAXSI55GX2GN6D67GK7BHVPSLJUGZQEU7WJ5LKR5PNUCGLIMAO4K" + +private fun sha256(data: ByteArray): ByteArray = MessageDigest.getInstance("SHA-256").digest(data) + +private fun buildMinimalWasmWithMeta(key: String, value: String): ByteArray { + val v0 = + SCMetaV0().apply { + this.key = XdrString(key.toByteArray(Charsets.UTF_8)) + this.`val` = XdrString(value.toByteArray(Charsets.UTF_8)) + } + val entry = + SCMetaEntry().apply { + discriminant = SCMetaKind.SC_META_V0 + this.v0 = v0 + } + val xdr = XdrStreams.serializeScMetaEntries(listOf(entry)) + return ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractmetav0", xdr)) + } + .toByteArray() +} + +private fun contractInstanceLedgerEntryXdr(executable: ContractExecutable): String { + val instance = + SCContractInstance().apply { + this.executable = executable + storage = SCMap(arrayOf()) + } + val value = + SCVal().apply { + discriminant = SCValType.SCV_CONTRACT_INSTANCE + this.instance = instance + } + val contractData = + ContractDataEntry.builder() + .ext(ExtensionPoint.builder().discriminant(0).build()) + .contract(Address(CONTRACT_ID).toSCAddress()) + .key(Scv.toLedgerKeyContractInstance()) + .durability(ContractDataDurability.PERSISTENT) + .`val`(value) + .build() + val ledgerEntryData = + LedgerEntry.LedgerEntryData.builder() + .discriminant(LedgerEntryType.CONTRACT_DATA) + .contractData(contractData) + .build() + return ledgerEntryData.toXdrBase64() +} + +private fun contractCodeLedgerEntryXdr(code: ByteArray): String { + val codeEntry = + ContractCodeEntry.builder() + .ext(ContractCodeEntry.ContractCodeEntryExt.builder().discriminant(0).build()) + .hash(Hash(sha256(code))) + .code(code) + .build() + val ledgerEntryData = + LedgerEntry.LedgerEntryData.builder() + .discriminant(LedgerEntryType.CONTRACT_CODE) + .contractCode(codeEntry) + .build() + return ledgerEntryData.toXdrBase64() +} + +private fun singleEntryJson(xdr: String): String = + """ + { + "jsonrpc": "2.0", + "id": "id", + "result": { + "entries": [ + { + "key": "key", + "xdr": "$xdr", + "lastModifiedLedgerSeq": "100", + "liveUntilLedgerSeq": "500" + } + ], + "latestLedger": "100" + } + } + """ + .trimIndent() + +private fun emptyEntriesJson(): String = + """ + { + "jsonrpc": "2.0", + "id": "id", + "result": { + "entries": [], + "latestLedger": "100" + } + } + """ + .trimIndent() + +private fun sequentialDispatcher(instanceJson: String, codeJson: String): Dispatcher = + object : Dispatcher() { + var call = 0 + + override fun dispatch(request: RecordedRequest): MockResponse { + call += 1 + return if (call == 1) { + MockResponse().setResponseCode(200).setBody(instanceJson) + } else { + MockResponse().setResponseCode(200).setBody(codeJson) + } + } + } + +class SorobanServerContractIntrospectionTest : + FunSpec({ + lateinit var mockWebServer: MockWebServer + + beforeTest { mockWebServer = MockWebServer() } + + afterTest { mockWebServer.shutdown() } + + fun newServer(): SorobanServer = SorobanServer(mockWebServer.url("").toString()) + + test("getContractWasmByHash returns code") { + val wasm = buildMinimalWasmWithMeta("k", "v") + val hash = sha256(wasm) + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(contractCodeLedgerEntryXdr(wasm))) + ) + mockWebServer.start() + + newServer().use { server -> server.getContractWasmByHash(hash) shouldBe wasm } + } + + test("getContractWasmByHash missing code throws") { + mockWebServer.enqueue(MockResponse().setBody(emptyEntriesJson())) + mockWebServer.start() + + newServer().use { server -> + val hash = ByteArray(32).apply { this[0] = 1 } + shouldThrow { server.getContractWasmByHash(hash) } + } + } + + test("getContractWasmByHash non-code entry throws") { + val executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) + .build() + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(contractInstanceLedgerEntryXdr(executable))) + ) + mockWebServer.start() + + newServer().use { server -> + val hash = ByteArray(32).apply { this[0] = 1 } + shouldThrow { server.getContractWasmByHash(hash) } + } + } + + test("getContractWasmByHash validates hash length") { + mockWebServer.start() + newServer().use { server -> + shouldThrow { server.getContractWasmByHash(ByteArray(16)) } + shouldThrow { server.getContractWasmByHash(null) } + } + } + + test("getContractWasmByHash rejects all-zero hash") { + mockWebServer.start() + newServer().use { server -> + shouldThrow { server.getContractWasmByHash(ByteArray(32)) } + } + } + + test("getContractWasm fetches instance then code") { + val wasm = buildMinimalWasmWithMeta("rsver", "1.78.0") + val executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) + .wasm_hash(Hash(sha256(wasm))) + .build() + mockWebServer.dispatcher = + sequentialDispatcher( + singleEntryJson(contractInstanceLedgerEntryXdr(executable)), + singleEntryJson(contractCodeLedgerEntryXdr(wasm)), + ) + mockWebServer.start() + + newServer().use { server -> server.getContractWasm(CONTRACT_ID) shouldBe wasm } + } + + test("getContractWasm stellar asset throws") { + val executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) + .build() + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(contractInstanceLedgerEntryXdr(executable))) + ) + mockWebServer.start() + + newServer().use { server -> + shouldThrow { server.getContractWasm(CONTRACT_ID) } + } + } + + test("getContractWasm missing instance throws") { + mockWebServer.enqueue(MockResponse().setBody(emptyEntriesJson())) + mockWebServer.start() + + newServer().use { server -> + shouldThrow { server.getContractWasm(CONTRACT_ID) } + } + } + + test("getContractWasm invalid contract id throws") { + mockWebServer.start() + newServer().use { server -> + shouldThrow { server.getContractWasm("not-a-contract") } + } + } + + test("getContractMeta parses fetched Wasm") { + val wasm = buildMinimalWasmWithMeta("rsver", "1.78.0") + val executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) + .wasm_hash(Hash(sha256(wasm))) + .build() + mockWebServer.dispatcher = + sequentialDispatcher( + singleEntryJson(contractInstanceLedgerEntryXdr(executable)), + singleEntryJson(contractCodeLedgerEntryXdr(wasm)), + ) + mockWebServer.start() + + newServer().use { server -> + server.getContractMeta(CONTRACT_ID).get("rsver") shouldBe Optional.of("1.78.0") + } + } + + test( + "getContractWasm unknown executable kind in raw XDR throws ContractWasmRetrievalException" + ) { + // Encode a valid STELLAR_ASSET LedgerEntryData, then mutate the executable discriminant to + // an unknown value. The LedgerEntryData layout places the executable discriminant at a + // fixed offset: + // 4 (LedgerEntryType=CONTRACT_DATA) + 4 (ext v0) + 36 (SCAddress contract) + + // 4 (SCVal key) + 4 (durability) + 4 (SCVal val discriminant) = 56 + val executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) + .build() + val xdrBase64 = contractInstanceLedgerEntryXdr(executable) + val bytes = java.util.Base64.getDecoder().decode(xdrBase64) + // Patch the 4 bytes at offset 56 to 99 (unknown discriminant). + bytes[56] = 0x00 + bytes[57] = 0x00 + bytes[58] = 0x00 + bytes[59] = 0x63 + val mutated = java.util.Base64.getEncoder().encodeToString(bytes) + + mockWebServer.enqueue(MockResponse().setBody(singleEntryJson(mutated))) + mockWebServer.start() + + newServer().use { server -> + shouldThrow { server.getContractWasm(CONTRACT_ID) } + } + } + + test("getContractWasmByHash malformed XDR throws ContractWasmRetrievalException") { + mockWebServer.enqueue(MockResponse().setBody(singleEntryJson("not-valid-base64!@#"))) + mockWebServer.start() + + newServer().use { server -> + val hash = ByteArray(32).apply { this[0] = 1 } + shouldThrow { server.getContractWasmByHash(hash) } + } + } + + test("getContractInfo parses fetched Wasm") { + val wasm = buildMinimalWasmWithMeta("sep", "41,40") + val executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) + .wasm_hash(Hash(sha256(wasm))) + .build() + mockWebServer.dispatcher = + sequentialDispatcher( + singleEntryJson(contractInstanceLedgerEntryXdr(executable)), + singleEntryJson(contractCodeLedgerEntryXdr(wasm)), + ) + mockWebServer.start() + + newServer().use { server -> + val info = server.getContractInfo(CONTRACT_ID) + info.meta.supportedSeps() shouldBe listOf(41, 40) + info.spec.entries.isEmpty() shouldBe true + } + } + }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/WasmCustomSectionsTest.kt b/src/test/kotlin/org/stellar/sdk/contract/WasmCustomSectionsTest.kt new file mode 100644 index 000000000..a7a030237 --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/WasmCustomSectionsTest.kt @@ -0,0 +1,125 @@ +package org.stellar.sdk.contract + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.io.ByteArrayOutputStream +import org.stellar.sdk.contract.exception.InvalidWasmException + +class WasmCustomSectionsTest : + FunSpec({ + test("empty valid Wasm returns no sections") { + val sections = WasmCustomSections.getCustomSections(WasmTestSupport.WASM_HEADER) + sections.isEmpty() shouldBe true + } + + test("multiple same-name sections returned in order") { + val wasm = + WasmTestSupport.wasmWithSections( + "contractmetav0" to byteArrayOf(0x01), + "contractmetav0" to byteArrayOf(0x02), + ) + val payloads = WasmCustomSections.getCustomSections(wasm, "contractmetav0") + payloads.size shouldBe 2 + payloads[0] shouldBe byteArrayOf(0x01) + payloads[1] shouldBe byteArrayOf(0x02) + } + + test("unrelated sections are ignored") { + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildOtherSection(1, byteArrayOf(0x00, 0x00))) + write(WasmTestSupport.buildCustomSection("contractmetav0", byteArrayOf(0x42))) + write(WasmTestSupport.buildOtherSection(3, byteArrayOf(0x00, 0x00))) + } + .toByteArray() + val payloads = WasmCustomSections.getCustomSections(wasm, "contractmetav0") + payloads.size shouldBe 1 + payloads[0] shouldBe byteArrayOf(0x42) + } + + test("multi-byte LEB128 section length is supported") { + val payload = ByteArray(200) { (it and 0xff).toByte() } + val wasm = WasmTestSupport.wasmWithSections("contractmetav0" to payload) + val payloads = WasmCustomSections.getCustomSections(wasm, "contractmetav0") + payloads.size shouldBe 1 + payloads[0] shouldBe payload + } + + test("invalid header throws") { + val wasm = byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00) + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + + test("short header throws") { + val wasm = byteArrayOf(0x00, 0x61, 0x73, 0x6d) + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + + test("invalid version throws") { + val wasm = byteArrayOf(0x00, 0x61, 0x73, 0x6d, 0x02, 0x00, 0x00, 0x00) + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + + test("section extending past EOF throws") { + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(0x00) // custom section id + write(WasmTestSupport.encodeUnsignedLeb128(100)) // claims 100 bytes + write(byteArrayOf(0x01)) // but only 1 byte follows + } + .toByteArray() + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + + test("truncated LEB128 throws") { + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(0x00) + write(0x80) // continuation bit set, no following byte + } + .toByteArray() + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + + test("too-long LEB128 throws") { + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(0x00) + // 6 bytes with continuation bits — exceeds 5-byte limit for u32 + repeat(5) { write(0x80) } + write(0x01) + } + .toByteArray() + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + + test("non-UTF-8 section name throws") { + val sectionBody = + ByteArrayOutputStream() + .apply { + write(0x02) // name length = 2 + write(0xff) + write(0xff) + } + .toByteArray() + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(0x00) + write(WasmTestSupport.encodeUnsignedLeb128(sectionBody.size.toLong())) + write(sectionBody) + } + .toByteArray() + shouldThrow { WasmCustomSections.getCustomSections(wasm) } + } + }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/WasmTestSupport.kt b/src/test/kotlin/org/stellar/sdk/contract/WasmTestSupport.kt new file mode 100644 index 000000000..234355859 --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/WasmTestSupport.kt @@ -0,0 +1,58 @@ +package org.stellar.sdk.contract + +import java.io.ByteArrayOutputStream + +internal object WasmTestSupport { + val WASM_HEADER: ByteArray = byteArrayOf(0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00) + + fun buildCustomSection(name: String, payload: ByteArray): ByteArray { + val nameBytes = name.toByteArray(Charsets.UTF_8) + val body = + ByteArrayOutputStream().apply { + write(encodeUnsignedLeb128(nameBytes.size.toLong())) + write(nameBytes) + write(payload) + } + return ByteArrayOutputStream() + .apply { + write(0x00) + write(encodeUnsignedLeb128(body.size().toLong())) + write(body.toByteArray()) + } + .toByteArray() + } + + fun buildOtherSection(id: Int, payload: ByteArray): ByteArray = + ByteArrayOutputStream() + .apply { + write(id) + write(encodeUnsignedLeb128(payload.size.toLong())) + write(payload) + } + .toByteArray() + + fun encodeUnsignedLeb128(value: Long): ByteArray { + require(value >= 0) { "value must be non-negative" } + val out = ByteArrayOutputStream() + var remaining = value + do { + var b = (remaining and 0x7f).toInt() + remaining = remaining ushr 7 + if (remaining != 0L) { + b = b or 0x80 + } + out.write(b) + } while (remaining != 0L) + return out.toByteArray() + } + + fun wasmWithSections(vararg sections: Pair): ByteArray = + ByteArrayOutputStream() + .apply { + write(WASM_HEADER) + for ((name, payload) in sections) { + write(buildCustomSection(name, payload)) + } + } + .toByteArray() +} diff --git a/src/test/kotlin/org/stellar/sdk/contract/XdrStreamsTest.kt b/src/test/kotlin/org/stellar/sdk/contract/XdrStreamsTest.kt new file mode 100644 index 000000000..dde8a0cdb --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/contract/XdrStreamsTest.kt @@ -0,0 +1,59 @@ +package org.stellar.sdk.contract + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.stellar.sdk.contract.exception.InvalidWasmException +import org.stellar.sdk.xdr.SCMetaEntry +import org.stellar.sdk.xdr.SCMetaKind +import org.stellar.sdk.xdr.SCMetaV0 +import org.stellar.sdk.xdr.XdrString + +private fun meta(key: String, value: String): SCMetaEntry { + val v0 = + SCMetaV0().apply { + this.key = XdrString(key.toByteArray(Charsets.UTF_8)) + this.`val` = XdrString(value.toByteArray(Charsets.UTF_8)) + } + return SCMetaEntry().apply { + discriminant = SCMetaKind.SC_META_V0 + this.v0 = v0 + } +} + +class XdrStreamsTest : + FunSpec({ + test("round-trip multiple SCMetaEntry values") { + val entries = + listOf(meta("rsver", "1.78.0"), meta("rssdkver", "21.0.0"), meta("sep", "41,40")) + val bytes = XdrStreams.serializeScMetaEntries(entries) + XdrStreams.parseScMetaEntries(bytes) shouldBe entries + } + + test("empty stream produces empty list") { + XdrStreams.parseScMetaEntries(ByteArray(0)).isEmpty() shouldBe true + } + + test("serialize empty iterable produces empty byte array") { + XdrStreams.serializeScMetaEntries(emptyList()) shouldBe ByteArray(0) + } + + test("truncated XDR throws") { + val bytes = XdrStreams.serializeScMetaEntries(listOf(meta("k", "v"))) + val truncated = bytes.copyOfRange(0, bytes.size - 1) + shouldThrow { XdrStreams.parseScMetaEntries(truncated) } + } + + test("trailing bytes are rejected") { + val bytes = XdrStreams.serializeScMetaEntries(listOf(meta("k", "v"))) + val withGarbage = bytes + byteArrayOf(0x01) + shouldThrow { XdrStreams.parseScMetaEntries(withGarbage) } + } + + test("invalid discriminant throws") { + // SCMetaKind only defines SC_META_V0 = 0; value 1 should fail decode. + shouldThrow { + XdrStreams.parseScMetaEntries(byteArrayOf(0x00, 0x00, 0x00, 0x01)) + } + } + }) From 961f542de5c2d41239957025e2e37beef2224bd5 Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Tue, 26 May 2026 16:07:43 +0800 Subject: [PATCH 2/7] wip --- .../stellar/sdk/contract/ContractMeta.java | 45 ++--- .../stellar/sdk/contract/ContractSpec.java | 154 +++++++----------- .../java/org/stellar/sdk/contract/Utf8.java | 33 ++++ .../sdk/contract/WasmCustomSections.java | 14 +- .../stellar/sdk/contract/ContractMetaTest.kt | 31 +++- .../SorobanServerContractIntrospectionTest.kt | 2 +- 6 files changed, 138 insertions(+), 141 deletions(-) create mode 100644 src/main/java/org/stellar/sdk/contract/Utf8.java diff --git a/src/main/java/org/stellar/sdk/contract/ContractMeta.java b/src/main/java/org/stellar/sdk/contract/ContractMeta.java index 9d27eba25..845d319c9 100644 --- a/src/main/java/org/stellar/sdk/contract/ContractMeta.java +++ b/src/main/java/org/stellar/sdk/contract/ContractMeta.java @@ -2,16 +2,12 @@ import java.io.IOException; import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; -import java.util.Iterator; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -39,7 +35,7 @@ @Getter @EqualsAndHashCode @ToString -public final class ContractMeta implements Iterable { +public final class ContractMeta { private final List entries; public ContractMeta() { @@ -157,10 +153,15 @@ public List getAll(String key) { } /** - * Returns SEP-0047 SEP identifiers declared via {@code sep} metadata entries. Values are returned - * in first-seen order with duplicates removed. Invalid identifiers are skipped. + * Returns SEP-0047 SEP identifiers declared via {@code sep} metadata entries. Invalid identifiers + * are skipped. + * + *

SEP-0047 treats the declared identifiers as an unordered set: the value "may be in any + * order" and the same identifier may be repeated across entries. The returned set is + * de-duplicated; its iteration order is the first-seen order purely for deterministic output and + * carries no meaning. */ - public List supportedSeps() { + public Set supportedSeps() { return supportedSeps(false); } @@ -172,9 +173,8 @@ public List supportedSeps() { * @throws IllegalArgumentException if {@code strict} is true and an identifier is invalid * @throws InvalidWasmException if a key or value is not valid UTF-8 */ - public List supportedSeps(boolean strict) { - Set seen = new HashSet<>(); - List supported = new ArrayList<>(); + public Set supportedSeps(boolean strict) { + Set supported = new LinkedHashSet<>(); for (String value : getAll("sep")) { for (String rawPart : value.split(",", -1)) { String sep = rawPart.trim(); @@ -190,21 +190,16 @@ public List supportedSeps(boolean strict) { } continue; } - int sepNumber; try { - sepNumber = Integer.parseInt(sep); + supported.add(Integer.parseInt(sep)); } catch (NumberFormatException e) { if (strict) { throw new IllegalArgumentException("Invalid SEP identifier: '" + sep + "'.", e); } - continue; - } - if (seen.add(sepNumber)) { - supported.add(sepNumber); } } } - return Collections.unmodifiableList(supported); + return Collections.unmodifiableSet(supported); } /** Returns whether the contract declares support for {@code sep} via SEP-0047. */ @@ -212,19 +207,9 @@ public boolean implementsSep(int sep) { return supportedSeps().contains(sep); } - @Override - public Iterator iterator() { - return entries.iterator(); - } - private static String decodeMetaString(byte[] data) { - CharsetDecoder decoder = - StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); try { - return decoder.decode(java.nio.ByteBuffer.wrap(data)).toString(); + return Utf8.strictDecode(data); } catch (CharacterCodingException e) { throw new InvalidWasmException("Contract meta contains a non-UTF-8 string.", e); } diff --git a/src/main/java/org/stellar/sdk/contract/ContractSpec.java b/src/main/java/org/stellar/sdk/contract/ContractSpec.java index 760adcb1c..c5a02cebb 100644 --- a/src/main/java/org/stellar/sdk/contract/ContractSpec.java +++ b/src/main/java/org/stellar/sdk/contract/ContractSpec.java @@ -2,16 +2,13 @@ import java.io.IOException; import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collections; -import java.util.Iterator; import java.util.List; import java.util.Optional; +import java.util.function.Function; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; @@ -30,18 +27,28 @@ /** * Represents a SEP-0048 contract interface specification. * - *

Entries are stored in module order and exposed as an unmodifiable list. Construct with raw - * {@link SCSpecEntry} values, decode from contract Wasm bytes via {@link #fromWasm(byte[])}, or - * decode from a SEP-0048 XDR stream via {@link #fromXdrBytes(byte[])}. + *

Entries are stored in module order and exposed as an unmodifiable list. The classified views + * ({@link #getFunctions()}, {@link #getEvents()}, etc.) are computed once at construction. + * Construct with raw {@link SCSpecEntry} values, decode from contract Wasm bytes via {@link + * #fromWasm(byte[])}, or decode from a SEP-0048 XDR stream via {@link #fromXdrBytes(byte[])}. + * + *

SEP-0048 does not require entry names to be unique, so the {@code getFunction}/{@code + * getEvent}/{@code getUdt} lookups return the first matching entry in module order. * * @see SEP-0048 */ @Getter -@EqualsAndHashCode -@ToString -public final class ContractSpec implements Iterable { +@EqualsAndHashCode(of = "entries") +@ToString(of = "entries") +public final class ContractSpec { private final List entries; + private final List functions; + private final List events; + private final List structs; + private final List unions; + private final List enums; + private final List errorEnums; public ContractSpec() { this(Collections.emptyList()); @@ -59,6 +66,39 @@ public ContractSpec(List entries) { copy.add(entry); } this.entries = Collections.unmodifiableList(copy); + this.functions = + classify( + this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0, SCSpecEntry::getFunctionV0); + this.events = + classify(this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_EVENT_V0, SCSpecEntry::getEventV0); + this.structs = + classify( + this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_UDT_STRUCT_V0, SCSpecEntry::getUdtStructV0); + this.unions = + classify( + this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_UDT_UNION_V0, SCSpecEntry::getUdtUnionV0); + this.enums = + classify( + this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ENUM_V0, SCSpecEntry::getUdtEnumV0); + this.errorEnums = + classify( + this.entries, + SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0, + SCSpecEntry::getUdtErrorEnumV0); + } + + private static List classify( + List entries, SCSpecEntryKind kind, Function extractor) { + List result = new ArrayList<>(); + for (SCSpecEntry entry : entries) { + if (entry.getDiscriminant() == kind) { + T value = extractor.apply(entry); + if (value != null) { + result.add(value); + } + } + } + return Collections.unmodifiableList(result); } /** @@ -110,74 +150,9 @@ public byte[] toXdrBytes() { return XdrStreams.serializeScSpecEntries(entries); } - public List getFunctions() { - List functions = new ArrayList<>(); - for (SCSpecEntry entry : entries) { - if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0 - && entry.getFunctionV0() != null) { - functions.add(entry.getFunctionV0()); - } - } - return Collections.unmodifiableList(functions); - } - - public List getEvents() { - List events = new ArrayList<>(); - for (SCSpecEntry entry : entries) { - if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_EVENT_V0 - && entry.getEventV0() != null) { - events.add(entry.getEventV0()); - } - } - return Collections.unmodifiableList(events); - } - - public List getStructs() { - List structs = new ArrayList<>(); - for (SCSpecEntry entry : entries) { - if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_STRUCT_V0 - && entry.getUdtStructV0() != null) { - structs.add(entry.getUdtStructV0()); - } - } - return Collections.unmodifiableList(structs); - } - - public List getUnions() { - List unions = new ArrayList<>(); - for (SCSpecEntry entry : entries) { - if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_UNION_V0 - && entry.getUdtUnionV0() != null) { - unions.add(entry.getUdtUnionV0()); - } - } - return Collections.unmodifiableList(unions); - } - - public List getEnums() { - List enums = new ArrayList<>(); - for (SCSpecEntry entry : entries) { - if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ENUM_V0 - && entry.getUdtEnumV0() != null) { - enums.add(entry.getUdtEnumV0()); - } - } - return Collections.unmodifiableList(enums); - } - - public List getErrorEnums() { - List errorEnums = new ArrayList<>(); - for (SCSpecEntry entry : entries) { - if (entry.getDiscriminant() == SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0 - && entry.getUdtErrorEnumV0() != null) { - errorEnums.add(entry.getUdtErrorEnumV0()); - } - } - return Collections.unmodifiableList(errorEnums); - } - /** - * Returns the function with the given name, if present. + * Returns the first function with the given name, if present. SEP-0048 does not require names to + * be unique. * * @throws InvalidWasmException if a function name is not valid UTF-8 */ @@ -194,7 +169,8 @@ public Optional getFunction(String name) { } /** - * Returns the event with the given name, if present. + * Returns the first event with the given name, if present. SEP-0048 does not require names to be + * unique. * * @throws InvalidWasmException if an event name is not valid UTF-8 */ @@ -211,8 +187,8 @@ public Optional getEvent(String name) { } /** - * Returns the user-defined type entry (struct, union, enum, or error enum) with the given name, - * if present. + * Returns the first user-defined type entry (struct, union, enum, or error enum) with the given + * name, if present. SEP-0048 does not require names to be unique. * * @throws InvalidWasmException if a type name is not valid UTF-8 */ @@ -229,11 +205,6 @@ public Optional getUdt(String name) { return Optional.empty(); } - @Override - public Iterator iterator() { - return entries.iterator(); - } - private static String getUdtName(SCSpecEntry entry) { switch (entry.getDiscriminant()) { case SC_SPEC_ENTRY_UDT_STRUCT_V0: @@ -257,24 +228,19 @@ private static String decodeSymbol(SCSymbol symbol) { if (symbol == null || symbol.getSCSymbol() == null) { throw new InvalidWasmException("Contract spec contains a null symbol."); } - return decodeUtf8Strict(symbol.getSCSymbol().getBytes(), "symbol"); + return decodeName(symbol.getSCSymbol().getBytes(), "symbol"); } private static String decodeString(XdrString value) { if (value == null) { throw new InvalidWasmException("Contract spec contains a null string."); } - return decodeUtf8Strict(value.getBytes(), "string"); + return decodeName(value.getBytes(), "string"); } - private static String decodeUtf8Strict(byte[] data, String kind) { - CharsetDecoder decoder = - StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); + private static String decodeName(byte[] data, String kind) { try { - return decoder.decode(java.nio.ByteBuffer.wrap(data)).toString(); + return Utf8.strictDecode(data); } catch (CharacterCodingException e) { throw new InvalidWasmException("Contract spec contains a non-UTF-8 " + kind + ".", e); } diff --git a/src/main/java/org/stellar/sdk/contract/Utf8.java b/src/main/java/org/stellar/sdk/contract/Utf8.java new file mode 100644 index 000000000..d93beda37 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/Utf8.java @@ -0,0 +1,33 @@ +package org.stellar.sdk.contract; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; + +/** + * Strict UTF-8 decoding shared by the contract introspection parsers. + * + *

Unlike {@code new String(bytes, UTF_8)}, this rejects malformed or unmappable byte sequences + * instead of silently substituting replacement characters, so callers can surface invalid contract + * data as an error. + */ +final class Utf8 { + private Utf8() {} + + /** + * Decodes {@code data} as UTF-8. + * + * @param data the bytes to decode + * @return the decoded string + * @throws CharacterCodingException if the bytes are not valid UTF-8 + */ + static String strictDecode(byte[] data) throws CharacterCodingException { + return StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(data)) + .toString(); + } +} diff --git a/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java b/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java index 50c0473cc..7109c6ed9 100644 --- a/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java +++ b/src/main/java/org/stellar/sdk/contract/WasmCustomSections.java @@ -1,9 +1,6 @@ package org.stellar.sdk.contract; import java.nio.charset.CharacterCodingException; -import java.nio.charset.CharsetDecoder; -import java.nio.charset.CodingErrorAction; -import java.nio.charset.StandardCharsets; import java.util.AbstractMap; import java.util.ArrayList; import java.util.Arrays; @@ -75,7 +72,7 @@ static List> getCustomSections(byte[] wasm) { "Invalid Wasm custom section: name extends past section end."); } int nameEnd = (int) nameEndLong; - String name = decodeUtf8Strict(wasm, nameStart, nameEnd - nameStart); + String name = decodeSectionName(Arrays.copyOfRange(wasm, nameStart, nameEnd)); byte[] payload = Arrays.copyOfRange(wasm, nameEnd, sectionEnd); sections.add(new AbstractMap.SimpleImmutableEntry<>(name, payload)); } @@ -134,14 +131,9 @@ private static long[] readU32Leb128(byte[] data, int offset, int limit) { throw new InvalidWasmException("Invalid Wasm module: LEB128 value is too long."); } - private static String decodeUtf8Strict(byte[] data, int offset, int length) { - CharsetDecoder decoder = - StandardCharsets.UTF_8 - .newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT); + private static String decodeSectionName(byte[] data) { try { - return decoder.decode(java.nio.ByteBuffer.wrap(data, offset, length)).toString(); + return Utf8.strictDecode(data); } catch (CharacterCodingException e) { throw new InvalidWasmException("Invalid Wasm custom section: name is not UTF-8.", e); } diff --git a/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt b/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt index d152de7d7..2462af03d 100644 --- a/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt +++ b/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt @@ -68,24 +68,26 @@ class ContractMetaTest : test("supportedSeps parses comma-separated") { val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41,40"))) - meta.supportedSeps() shouldBe listOf(41, 40) + meta.supportedSeps() shouldBe setOf(41, 40) meta.implementsSep(40) shouldBe true meta.implementsSep(42) shouldBe false } - test("supportedSeps merges multiple entries") { + test("supportedSeps merges multiple entries and preserves first-seen order") { val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41"), meta("sep", "40,46"))) - meta.supportedSeps() shouldBe listOf(41, 40, 46) + meta.supportedSeps() shouldBe setOf(41, 40, 46) + // Iteration order is deterministic (first-seen), though SEP-0047 assigns it no meaning. + meta.supportedSeps().toList() shouldBe listOf(41, 40, 46) } test("supportedSeps accepts leading zeros and deduplicates") { val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "041"), meta("sep", "41,41,40"))) - meta.supportedSeps() shouldBe listOf(41, 40) + meta.supportedSeps() shouldBe setOf(41, 40) } test("supportedSeps skips invalid by default") { val meta = ContractMeta.fromWasm(wasmWith(meta("sep", "41, ,abc,40"))) - meta.supportedSeps() shouldBe listOf(41, 40) + meta.supportedSeps() shouldBe setOf(41, 40) } test("supportedSeps rejects invalid in strict mode") { @@ -127,4 +129,23 @@ class ContractMetaTest : test("constructor rejects null elements") { shouldThrow { ContractMeta(listOf(meta("k", "v"), null)) } } + + // SEP-0046: "entries should not span sections". Splitting a single SCMetaEntry across two + // contractmetav0 sections must be rejected, because each section is decoded as a + // self-contained stream rather than the bytes being concatenated first. + test("entries must not span sections") { + val entryBytes = XdrStreams.serializeScMetaEntries(listOf(meta("rsver", "1.78.0"))) + val split = entryBytes.size / 2 + val firstHalf = entryBytes.copyOfRange(0, split) + val secondHalf = entryBytes.copyOfRange(split, entryBytes.size) + val wasm = + ByteArrayOutputStream() + .apply { + write(WasmTestSupport.WASM_HEADER) + write(WasmTestSupport.buildCustomSection("contractmetav0", firstHalf)) + write(WasmTestSupport.buildCustomSection("contractmetav0", secondHalf)) + } + .toByteArray() + shouldThrow { ContractMeta.fromWasm(wasm) } + } }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt index 14600a73d..7d2fe8a98 100644 --- a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt +++ b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt @@ -333,7 +333,7 @@ class SorobanServerContractIntrospectionTest : newServer().use { server -> val info = server.getContractInfo(CONTRACT_ID) - info.meta.supportedSeps() shouldBe listOf(41, 40) + info.meta.supportedSeps() shouldBe setOf(41, 40) info.spec.entries.isEmpty() shouldBe true } } From 855195e500cf8370f1cfe8057d4c3b6b446aa632 Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Tue, 26 May 2026 16:45:00 +0800 Subject: [PATCH 3/7] wip --- .../stellar/sdk/contract/ContractMeta.java | 4 ++ .../stellar/sdk/contract/ContractSpec.java | 72 ++++++++++--------- 2 files changed, 42 insertions(+), 34 deletions(-) diff --git a/src/main/java/org/stellar/sdk/contract/ContractMeta.java b/src/main/java/org/stellar/sdk/contract/ContractMeta.java index 845d319c9..43e4fa7e7 100644 --- a/src/main/java/org/stellar/sdk/contract/ContractMeta.java +++ b/src/main/java/org/stellar/sdk/contract/ContractMeta.java @@ -27,6 +27,10 @@ * {@link SCMetaEntry} values, decode from contract Wasm bytes via {@link #fromWasm(byte[])}, or * decode from a SEP-0046 XDR stream via {@link #fromXdrBytes(byte[])}. * + *

This wrapper is shallow immutable: the entry list cannot be modified, but the contained {@link + * SCMetaEntry} objects are the underlying mutable XDR types. Do not mutate them after construction; + * doing so also affects {@link #equals(Object)}, {@link #hashCode()}, and the decoded views. + * * @see SEP-0046 * @see Entries are stored in module order and exposed as an unmodifiable list. The classified views - * ({@link #getFunctions()}, {@link #getEvents()}, etc.) are computed once at construction. - * Construct with raw {@link SCSpecEntry} values, decode from contract Wasm bytes via {@link - * #fromWasm(byte[])}, or decode from a SEP-0048 XDR stream via {@link #fromXdrBytes(byte[])}. + * ({@link #getFunctions()}, {@link #getEvents()}, etc.) are derived from {@link #getEntries()} on + * each call. Construct with raw {@link SCSpecEntry} values, decode from contract Wasm bytes via + * {@link #fromWasm(byte[])}, or decode from a SEP-0048 XDR stream via {@link + * #fromXdrBytes(byte[])}. * *

SEP-0048 does not require entry names to be unique, so the {@code getFunction}/{@code * getEvent}/{@code getUdt} lookups return the first matching entry in module order. * + *

This wrapper is shallow immutable: the entry list cannot be modified, but the contained {@link + * SCSpecEntry} objects are the underlying mutable XDR types. Do not mutate them after construction; + * doing so also affects {@link #equals(Object)}, {@link #hashCode()}, and the classified views. + * * @see SEP-0048 */ @Getter -@EqualsAndHashCode(of = "entries") -@ToString(of = "entries") +@EqualsAndHashCode +@ToString public final class ContractSpec { private final List entries; - private final List functions; - private final List events; - private final List structs; - private final List unions; - private final List enums; - private final List errorEnums; public ContractSpec() { this(Collections.emptyList()); @@ -66,29 +65,34 @@ public ContractSpec(List entries) { copy.add(entry); } this.entries = Collections.unmodifiableList(copy); - this.functions = - classify( - this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0, SCSpecEntry::getFunctionV0); - this.events = - classify(this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_EVENT_V0, SCSpecEntry::getEventV0); - this.structs = - classify( - this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_UDT_STRUCT_V0, SCSpecEntry::getUdtStructV0); - this.unions = - classify( - this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_UDT_UNION_V0, SCSpecEntry::getUdtUnionV0); - this.enums = - classify( - this.entries, SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ENUM_V0, SCSpecEntry::getUdtEnumV0); - this.errorEnums = - classify( - this.entries, - SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0, - SCSpecEntry::getUdtErrorEnumV0); - } - - private static List classify( - List entries, SCSpecEntryKind kind, Function extractor) { + } + + public List getFunctions() { + return classify(SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0, SCSpecEntry::getFunctionV0); + } + + public List getEvents() { + return classify(SCSpecEntryKind.SC_SPEC_ENTRY_EVENT_V0, SCSpecEntry::getEventV0); + } + + public List getStructs() { + return classify(SCSpecEntryKind.SC_SPEC_ENTRY_UDT_STRUCT_V0, SCSpecEntry::getUdtStructV0); + } + + public List getUnions() { + return classify(SCSpecEntryKind.SC_SPEC_ENTRY_UDT_UNION_V0, SCSpecEntry::getUdtUnionV0); + } + + public List getEnums() { + return classify(SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ENUM_V0, SCSpecEntry::getUdtEnumV0); + } + + public List getErrorEnums() { + return classify( + SCSpecEntryKind.SC_SPEC_ENTRY_UDT_ERROR_ENUM_V0, SCSpecEntry::getUdtErrorEnumV0); + } + + private List classify(SCSpecEntryKind kind, Function extractor) { List result = new ArrayList<>(); for (SCSpecEntry entry : entries) { if (entry.getDiscriminant() == kind) { From 08aae46eb9b0727f6c5bec0ab4031a6d7a73055f Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Tue, 26 May 2026 18:12:39 +0800 Subject: [PATCH 4/7] fix --- .../java/org/stellar/sdk/SorobanServer.java | 3 +-- .../stellar/sdk/contract/ContractMeta.java | 15 +++++++++--- .../stellar/sdk/contract/ContractSpec.java | 6 +++++ .../stellar/sdk/contract/ContractMetaTest.kt | 16 +++++++++++++ .../stellar/sdk/contract/ContractSpecTest.kt | 24 +++++++++++++++++++ 5 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 223a89553..9df5b470b 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -897,8 +897,7 @@ public ContractSpec getContractSpec(String contractId) { * *

This method issues two RPC requests (one for the contract instance ledger entry, one for the * contract code ledger entry) and parses the Wasm a single time. Prefer this over calling {@link - * #getContractMeta}, {@link #getContractSpec}, and this method separately when more than one view - * is needed. + * #getContractMeta} and {@link #getContractSpec} separately when more than one view is needed. * * @param contractId The contract ID, encoded as a Stellar Contract Address. * @return The parsed {@link ContractInfo}. diff --git a/src/main/java/org/stellar/sdk/contract/ContractMeta.java b/src/main/java/org/stellar/sdk/contract/ContractMeta.java index 43e4fa7e7..aea8bb8b6 100644 --- a/src/main/java/org/stellar/sdk/contract/ContractMeta.java +++ b/src/main/java/org/stellar/sdk/contract/ContractMeta.java @@ -61,8 +61,9 @@ public ContractMeta(List entries) { } /** - * Creates a {@link ContractMeta} from contract Wasm bytes by concatenating all {@code - * contractmetav0} custom sections in module order. + * Creates a {@link ContractMeta} from contract Wasm bytes by decoding each {@code contractmetav0} + * custom section as a self-contained XDR stream and appending the resulting entries in module + * order. Per SEP-0046, an entry never spans two sections. * * @param wasm contract Wasm bytes * @throws InvalidWasmException if the Wasm module or metadata section cannot be decoded @@ -110,10 +111,18 @@ public byte[] toXdrBytes() { public List> items() { List> items = new ArrayList<>(); for (SCMetaEntry entry : entries) { - if (entry.getDiscriminant() != SCMetaKind.SC_META_V0 || entry.getV0() == null) { + if (entry.getDiscriminant() != SCMetaKind.SC_META_V0) { + // Forward compatibility: ignore meta kinds this version does not understand. continue; } SCMetaV0 v0 = entry.getV0(); + if (v0 == null + || v0.getKey() == null + || v0.getVal() == null + || v0.getKey().getBytes() == null + || v0.getVal().getBytes() == null) { + throw new InvalidWasmException("Contract meta contains a malformed SC_META_V0 entry."); + } items.add( new AbstractMap.SimpleImmutableEntry<>( decodeMetaString(v0.getKey().getBytes()), decodeMetaString(v0.getVal().getBytes()))); diff --git a/src/main/java/org/stellar/sdk/contract/ContractSpec.java b/src/main/java/org/stellar/sdk/contract/ContractSpec.java index a87a3b1a1..1d30c5033 100644 --- a/src/main/java/org/stellar/sdk/contract/ContractSpec.java +++ b/src/main/java/org/stellar/sdk/contract/ContractSpec.java @@ -210,6 +210,9 @@ public Optional getUdt(String name) { } private static String getUdtName(SCSpecEntry entry) { + if (entry.getDiscriminant() == null) { + return null; + } switch (entry.getDiscriminant()) { case SC_SPEC_ENTRY_UDT_STRUCT_V0: return entry.getUdtStructV0() != null @@ -243,6 +246,9 @@ private static String decodeString(XdrString value) { } private static String decodeName(byte[] data, String kind) { + if (data == null) { + throw new InvalidWasmException("Contract spec contains a null " + kind + "."); + } try { return Utf8.strictDecode(data); } catch (CharacterCodingException e) { diff --git a/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt b/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt index 2462af03d..3a8291c4a 100644 --- a/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt +++ b/src/test/kotlin/org/stellar/sdk/contract/ContractMetaTest.kt @@ -130,6 +130,22 @@ class ContractMetaTest : shouldThrow { ContractMeta(listOf(meta("k", "v"), null)) } } + test("malformed SC_META_V0 with null v0 throws on access") { + val entry = SCMetaEntry().apply { discriminant = SCMetaKind.SC_META_V0 } + val meta = ContractMeta(listOf(entry)) + shouldThrow { meta.items() } + } + + test("malformed SC_META_V0 with null key bytes throws on access") { + val entry = + SCMetaEntry().apply { + discriminant = SCMetaKind.SC_META_V0 + v0 = SCMetaV0().apply { `val` = XdrString("v".toByteArray(Charsets.UTF_8)) } + } + val meta = ContractMeta(listOf(entry)) + shouldThrow { meta.items() } + } + // SEP-0046: "entries should not span sections". Splitting a single SCMetaEntry across two // contractmetav0 sections must be rejected, because each section is decoded as a // self-contained stream rather than the bytes being concatenated first. diff --git a/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt b/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt index 84645b208..ffd5d8cab 100644 --- a/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt +++ b/src/test/kotlin/org/stellar/sdk/contract/ContractSpecTest.kt @@ -219,4 +219,28 @@ class ContractSpecTest : test("constructor rejects null elements") { shouldThrow { ContractSpec(listOf(functionEntry("a"), null)) } } + + test("entry with null discriminant does not break getUdt lookup") { + val malformed = SCSpecEntry() + val spec = ContractSpec(listOf(malformed, structEntry("S"))) + spec.getUdt("S").isPresent shouldBe true + spec.getUdt("missing").isPresent shouldBe false + } + + test("null symbol bytes rejected by lookup with InvalidWasmException") { + val fn = + SCSpecFunctionV0().apply { + doc = XdrString(ByteArray(0)) + name = SCSymbol().apply { scSymbol = XdrString(null as ByteArray?) } + inputs = arrayOf() + outputs = arrayOf() + } + val entry = + SCSpecEntry().apply { + discriminant = SCSpecEntryKind.SC_SPEC_ENTRY_FUNCTION_V0 + functionV0 = fn + } + val spec = ContractSpec(listOf(entry)) + shouldThrow { spec.getFunction("anything") } + } }) From b2024bcf03b861a148b3aae01964dc1115058661 Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Tue, 26 May 2026 18:29:45 +0800 Subject: [PATCH 5/7] fix --- src/main/java/org/stellar/sdk/SorobanServer.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 9df5b470b..8fd6a2f50 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -723,8 +723,8 @@ public GetSACBalanceResponse getSACBalance(String contractId, Asset asset, Netwo * been archived. * @throws ContractWasmRetrievalException If the RPC response contains unexpected ledger entry * data. - * @throws org.stellar.sdk.exception.NetworkException All the exceptions below are subclasses of - * NetworkException + * @throws org.stellar.sdk.exception.NetworkException The following three exceptions are + * subclasses of NetworkException, thrown on RPC or transport failures. * @throws SorobanRpcException If the Stellar RPC instance returns an error response. * @throws RequestTimeoutException If the request timed out. * @throws ConnectionErrorException When the request cannot be executed due to cancellation or @@ -805,8 +805,8 @@ public byte[] getContractWasm(String contractId) { * been archived. * @throws ContractWasmRetrievalException If the RPC response contains unexpected ledger entry * data. - * @throws org.stellar.sdk.exception.NetworkException All the exceptions below are subclasses of - * NetworkException + * @throws org.stellar.sdk.exception.NetworkException The following three exceptions are + * subclasses of NetworkException, thrown on RPC or transport failures. * @throws SorobanRpcException If the Stellar RPC instance returns an error response. * @throws RequestTimeoutException If the request timed out. * @throws ConnectionErrorException When the request cannot be executed due to cancellation or From b0ac05a4e6a7db6bfc9e104984c0bfb9958609f7 Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Wed, 27 May 2026 10:05:22 +0800 Subject: [PATCH 6/7] fix --- src/main/java/org/stellar/sdk/SorobanServer.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 8fd6a2f50..87e219bb8 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -196,8 +196,7 @@ public GetFeeStatsResponse getFeeStats() { /** * Reads the current value of contract data ledger entries directly. * - * @param contractId The contract ID containing the data to load. Encoded as Stellar Contract - * Address. e.g. "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5" + * @param contractId The contract ID containing the data to load. * @param key The key of the contract data to load. * @param durability The "durability keyspace" that this ledger key belongs to, which is either * {@link Durability#TEMPORARY} or {@link Durability#PERSISTENT}. @@ -712,8 +711,7 @@ public GetSACBalanceResponse getSACBalance(String contractId, Asset asset, Netwo *

This first reads the contract instance ledger entry to discover the executable, then fetches * the {@code CONTRACT_CODE} ledger entry referenced by the instance. * - * @param contractId The contract ID. Encoded as a Stellar Contract Address, e.g. - * "CCJZ5DGASBWQXR5MPFCJXMBI333XE5U3FSJTNQU7RIKE3P5GN2K2WYD5". + * @param contractId The contract ID. Encoded as a Stellar Contract Address. * @return The contract Wasm bytecode. * @throws IllegalArgumentException If the contract ID is not a valid contract strkey. * @throws ContractInstanceNotFoundException If the contract instance ledger entry does not exist. From 9d3698c8bdece4493619110353864eadaef0b0b8 Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Wed, 27 May 2026 10:15:24 +0800 Subject: [PATCH 7/7] fix --- src/main/java/org/stellar/sdk/SorobanServer.java | 12 +----------- .../java/org/stellar/sdk/contract/XdrStreams.java | 10 ++++------ .../SorobanServerContractIntrospectionTest.kt | 7 ------- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index 87e219bb8..3778651c2 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -798,7 +798,7 @@ public byte[] getContractWasm(String contractId) { * * @param wasmHash The 32-byte Wasm hash. * @return The contract Wasm bytecode. - * @throws IllegalArgumentException If {@code wasmHash} is null, not 32 bytes, or all zero. + * @throws IllegalArgumentException If {@code wasmHash} is null or not 32 bytes. * @throws ContractCodeNotFoundException If the contract code ledger entry does not exist or has * been archived. * @throws ContractWasmRetrievalException If the RPC response contains unexpected ledger entry @@ -818,16 +818,6 @@ public byte[] getContractWasmByHash(byte[] wasmHash) { throw new IllegalArgumentException( "wasmHash must be 32 bytes, got " + wasmHash.length + " bytes"); } - boolean allZero = true; - for (byte b : wasmHash) { - if (b != 0) { - allZero = false; - break; - } - } - if (allZero) { - throw new IllegalArgumentException("wasmHash must not be all zero"); - } LedgerKey ledgerKey = LedgerKey.builder() diff --git a/src/main/java/org/stellar/sdk/contract/XdrStreams.java b/src/main/java/org/stellar/sdk/contract/XdrStreams.java index 11f0c3e02..8737adc58 100644 --- a/src/main/java/org/stellar/sdk/contract/XdrStreams.java +++ b/src/main/java/org/stellar/sdk/contract/XdrStreams.java @@ -42,15 +42,15 @@ static List parseScEnvMetaEntries(byte[] data) { } static byte[] serializeScMetaEntries(Iterable entries) { - return serializeStream(entries, (entry, stream) -> entry.encode(stream)); + return serializeStream(entries, SCMetaEntry::encode); } static byte[] serializeScSpecEntries(Iterable entries) { - return serializeStream(entries, (entry, stream) -> entry.encode(stream)); + return serializeStream(entries, SCSpecEntry::encode); } static byte[] serializeScEnvMetaEntries(Iterable entries) { - return serializeStream(entries, (entry, stream) -> entry.encode(stream)); + return serializeStream(entries, SCEnvMetaEntry::encode); } private static List parseStream(byte[] data, XdrDecoder decoder, String entryName) { @@ -67,9 +67,7 @@ private static List parseStream(byte[] data, XdrDecoder decoder, Strin T entry; try { entry = decoder.decode(xdrStream); - } catch (IOException e) { - throw new InvalidWasmException("Invalid XDR stream for " + entryName + ".", e); - } catch (IllegalArgumentException e) { + } catch (IOException | IllegalArgumentException e) { throw new InvalidWasmException("Invalid XDR stream for " + entryName + ".", e); } int currentAvailable = byteStream.available(); diff --git a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt index 7d2fe8a98..41245d04f 100644 --- a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt +++ b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt @@ -204,13 +204,6 @@ class SorobanServerContractIntrospectionTest : } } - test("getContractWasmByHash rejects all-zero hash") { - mockWebServer.start() - newServer().use { server -> - shouldThrow { server.getContractWasmByHash(ByteArray(32)) } - } - } - test("getContractWasm fetches instance then code") { val wasm = buildMinimalWasmWithMeta("rsver", "1.78.0") val executable =