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}.
+ *
+ * - Exceptions thrown by {@link org.stellar.sdk.contract.AssembledTransaction} during the
+ * lifecycle of a contract invocation (simulation failures, signing issues, submission errors,
+ * expired state). These all extend {@link
+ * org.stellar.sdk.contract.exception.AssembledTransactionException}.
+ *
- Exceptions thrown by contract introspection APIs ({@link
+ * org.stellar.sdk.contract.ContractMeta}, {@link org.stellar.sdk.contract.ContractSpec},
+ * {@link org.stellar.sdk.contract.ContractInfo}, and related {@link
+ * org.stellar.sdk.SorobanServer} methods). These all extend {@link
+ * org.stellar.sdk.contract.exception.ContractIntrospectionException}.
+ *
*
* @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 =