From 5836498ea1fe66f0fb7493643da7de0c44f89652 Mon Sep 17 00:00:00 2001 From: Jun Luo <4catcode@gmail.com> Date: Tue, 1 Sep 2026 11:02:16 +0800 Subject: [PATCH] feat: add CAP-85 external executable reference support --- CHANGELOG.md | 9 + .../java-stellar-sdk/references/operations.md | 1 + skills/java-stellar-sdk/references/soroban.md | 33 ++ .../references/troubleshooting.md | 12 + .../java-stellar-sdk/references/xdr_scval.md | 2 + .../java/org/stellar/sdk/SorobanServer.java | 146 ++++++++- src/main/java/org/stellar/sdk/Util.java | 31 ++ .../ExternalRefNotFoundException.java | 41 +++ .../InvokeHostFunctionOperation.java | 115 ++++++- src/main/java/org/stellar/sdk/scval/Scv.java | 47 +++ .../org/stellar/sdk/scval/ScvComparator.java | 22 +- .../stellar/sdk/scval/ScvExecutableTag.java | 28 ++ .../InvokeHostFunctionOperationTest.java | 187 +++++++++++ .../sdk/scval/ScvExecutableTagTest.java | 69 ++++ src/test/kotlin/org/stellar/sdk/UtilTest.kt | 23 ++ .../SorobanServerContractIntrospectionTest.kt | 309 ++++++++++++++++++ .../stellar/sdk/scval/ScvComparatorTest.kt | 113 +++++++ 17 files changed, 1175 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/stellar/sdk/contract/exception/ExternalRefNotFoundException.java create mode 100644 src/main/java/org/stellar/sdk/scval/ScvExecutableTag.java create mode 100644 src/test/java/org/stellar/sdk/scval/ScvExecutableTagTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 55737b681..c1e283ddf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,15 @@ - A hand-rolled signer that hardcodes the legacy `ENVELOPE_TYPE_SOROBAN_AUTHORIZATION` preimage now produces signatures the network rejects — build the payload with `Auth.buildAuthorizationEntryPreimage`, which picks the address-bound preimage off the entry. SDK-driven signing (`Auth.authorizeEntry`, `AssembledTransaction.signAuthEntries`, `ContractClient`) needs no change. ### Update +- feat: add CAP-85 external executable reference support. A contract instance can now hold a `CONTRACT_EXECUTABLE_EXTERNAL_REF` executable — an owner contract plus an owner-scoped tag — instead of its own Wasm hash. The owner publishes the Wasm hash in a persistent contract data entry keyed by `SCV_EXECUTABLE_TAG(tag)`, so it can upgrade every contract referencing that tag at once. ([#814](https://github.com/lightsail-network/java-stellar-sdk/issues/814)) + - New `SorobanServer.getExternalRefWasmHash(ContractExecutableExternalRef)` resolves a reference to the 32-byte Wasm hash it names, with a single `getLedgerEntries` call; the owner contract is not invoked. + - `SorobanServer.getContractWasm` — and so `getContractMeta`, `getContractSpec`, and `getContractInfo` — follows an external reference automatically, at the cost of that one extra request. + - New `ExternalRefNotFoundException` (a `ContractIntrospectionException`) is thrown when the tag entry is missing or archived. An unresolvable reference — one whose owner is not a contract, say, since only a contract can hold the tag entry — is rejected before any request is made: `getExternalRefWasmHash` throws `IllegalArgumentException`, where the reference is the caller's own argument, while `getContractWasm` reports it as `ContractWasmRetrievalException`, where it came off the ledger instead. Every `getContractWasm` failure therefore stays catchable as `ContractIntrospectionException`. + - New `InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder(...)` builds a `CREATE_CONTRACT_V2` operation that deploys from a reference instead of a Wasm hash, taking the owner contract plus a `String` or `byte[]` tag. + - New `Scv.toExecutableTag(String)` / `Scv.toExecutableTag(byte[])` / `Scv.fromExecutableTag(SCVal)` for the `SCV_EXECUTABLE_TAG` value. A tag is an unbounded `SCString` that need not be valid UTF-8 and identifies the code being deployed, so it is never decoded leniently: `fromExecutableTag` returns raw bytes, ledger keys are built from the original bytes, and binary tags are passed through undecoded. + - New `Util.decodeUtf8(byte[])` returns `Optional` — the text when the bytes are valid UTF-8, empty otherwise. Use it to display a tag, falling back to the raw bytes, instead of a lenient decode that would render two distinct tags identically. + - `ScvComparator` orders the new `SCV_EXECUTABLE_TAG` value (lexicographic unsigned bytes) and the new executable arm (by owner, then tag), so a contract instance holding an external reference sorts correctly. + - CAP-85 requires protocol 28. On an earlier network the new executable arm is not accepted, so deploying from a reference fails at submission; the read paths never encounter one, since no contract can hold a reference there. - feat: add `useUpgradedAuth` opt-outs at every layer that simulates: `SorobanServer.prepareTransaction(Transaction, boolean)`, `AssembledTransaction.simulate(boolean, boolean)`, and the `ContractClient.invoke` overload taking `useUpgradedAuth`. Derived transactions inherit the choice — the restore transaction `AssembledTransaction` builds during automatic restoration simulates with the same flag rather than falling back to the default. ([#814](https://github.com/lightsail-network/java-stellar-sdk/issues/814)) - feat: add a `useUpgradedAuth` opt-out to `Sep45Challenge.buildChallengeAuthorizationEntries`. SEP-45 does not specify a credential format, and the challenge entries are signed by a remote client rather than by the caller, so an anchor serving clients whose SDK cannot sign the address-bound payload can pass `false` to keep issuing legacy challenges. ([#814](https://github.com/lightsail-network/java-stellar-sdk/issues/814)) diff --git a/skills/java-stellar-sdk/references/operations.md b/skills/java-stellar-sdk/references/operations.md index 09332fbad..41a356027 100644 --- a/skills/java-stellar-sdk/references/operations.md +++ b/skills/java-stellar-sdk/references/operations.md @@ -97,6 +97,7 @@ factories for every host function: | `InvokeHostFunctionOperation.invokeContractFunctionOperationBuilder(contractId, functionName, parameters)` | Call a contract function | | `InvokeHostFunctionOperation.uploadContractWasmOperationBuilder(wasmBytes)` | Upload Wasm bytecode | | `InvokeHostFunctionOperation.createContractOperationBuilder(wasmId, address, constructorArgs, salt)` | Instantiate a contract | +| `InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder(owner, tag, address, constructorArgs, salt)` | Instantiate a contract from a CAP-85 external executable reference (owner contract + tag) instead of a Wasm hash | | `InvokeHostFunctionOperation.createStellarAssetContractOperationBuilder(asset)` | Deploy the SAC for a classic asset | | `ExtendFootprintTTLOperation` | Extend ledger entry TTL | | `RestoreFootprintOperation` | Restore archived state | diff --git a/skills/java-stellar-sdk/references/soroban.md b/skills/java-stellar-sdk/references/soroban.md index 45860475c..f5a5f9817 100644 --- a/skills/java-stellar-sdk/references/soroban.md +++ b/skills/java-stellar-sdk/references/soroban.md @@ -30,9 +30,41 @@ try (SorobanServer server = new SorobanServer("https://soroban-testnet.stellar.o server.getContractInfo(contractId); // SEP-48 (interface spec + meta) server.getContractMeta(contractId); // SEP-46 server.getContractSpec(contractId); // SEP-48 + server.getContractWasm(contractId); // raw Wasm; follows a CAP-85 external ref + server.getContractWasmByHash(wasmHash); + server.getExternalRefWasmHash(ref); // CAP-85: resolve a reference to a Wasm hash } ``` +### CAP-85 external executable references + +A contract's instance can hold a `CONTRACT_EXECUTABLE_EXTERNAL_REF` executable instead of its own +Wasm hash: an owner contract plus an owner-scoped tag. The owner publishes the Wasm hash in a +*persistent* contract data entry keyed by `SCV_EXECUTABLE_TAG(tag)`, so it can upgrade every +contract referencing that tag at once. + +Every Wasm-reading method above resolves the reference for you (one extra `getLedgerEntries` +call). To resolve one by hand: + +```java +ContractExecutable executable = instance.getExecutable(); +if (executable.getDiscriminant() == ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) { + byte[] wasmHash = server.getExternalRefWasmHash(executable.getExternal_ref()); + byte[] wasm = server.getContractWasmByHash(wasmHash); +} +``` + +A tag is an unbounded `SCString` and need not be valid UTF-8. Keep it as `byte[]`; only show it as +text when `Util.decodeUtf8(tag)` returns a value, and show the raw bytes otherwise. Never decode it +leniently — the tag is half of what identifies the code, so two distinct tags would render alike. +The owner must be a contract address; the SDK rejects anything else before making a request. +`ExternalRefNotFoundException` means the tag entry is missing or archived. An unresolvable reference +surfaces differently depending on where it came from: `getExternalRefWasmHash` throws +`IllegalArgumentException`, since the reference is your own argument, while `getContractWasm` (and +so `getContractMeta` / `getContractSpec` / `getContractInfo`) reports `ContractWasmRetrievalException`, +since there it came off the ledger — so every failure of those stays catchable as +`ContractIntrospectionException`. + ### Manual submit loop ```java @@ -181,6 +213,7 @@ assembled.signAndSubmit(submitter, false); `ContractClient` only invokes functions. To upload Wasm or create a contract, build an `InvokeHostFunctionOperation` with `SorobanServer` directly (see `operations.md`): `uploadContractWasmOperationBuilder(wasmBytes)`, `createContractOperationBuilder(...)`, +`createContractFromExternalRefOperationBuilder(owner, tag, address, ctorArgs, salt)` (CAP-85), `createStellarAssetContractOperationBuilder(asset)`. Prepare, sign, send, then read the Wasm ID / contract ID from the transaction meta. diff --git a/skills/java-stellar-sdk/references/troubleshooting.md b/skills/java-stellar-sdk/references/troubleshooting.md index 184094db4..859e9a558 100644 --- a/skills/java-stellar-sdk/references/troubleshooting.md +++ b/skills/java-stellar-sdk/references/troubleshooting.md @@ -30,6 +30,18 @@ Contract client lifecycle (in `org.stellar.sdk.contract.exception`, all extend `SendTransactionFailedException`, `TransactionStillPendingException`, `TransactionFailedException`, `NeedsMoreSignaturesException`, `NoSignatureNeededException`. +Contract introspection (in `org.stellar.sdk.contract.exception`, all extend +`ContractIntrospectionException` → `SdkException`) — raised by `ContractMeta`, `ContractSpec`, +`ContractInfo` and the `SorobanServer` methods that read a contract's Wasm: +- `ContractInstanceNotFoundException` — the contract instance ledger entry does not exist. +- `ContractCodeNotFoundException` — the contract code entry is missing or archived. +- `StellarAssetContractHasNoWasmException` — the contract is a SAC, which has no Wasm on-chain. +- `ExternalRefNotFoundException` — the CAP-85 tag entry that an external executable reference points + at is missing or archived. +- `ContractWasmRetrievalException` — the RPC response held unexpected ledger entry data, including + an external executable reference that cannot be resolved. +- `InvalidWasmException` — the Wasm was fetched but could not be parsed. + Other: - `AccountRequiresMemoException` — destination requires a memo (SEP-29). - `InvalidSep10ChallengeException`, `InvalidSep45ChallengeException` — challenge validation. diff --git a/skills/java-stellar-sdk/references/xdr_scval.md b/skills/java-stellar-sdk/references/xdr_scval.md index 3d81b6c8b..26fae7c44 100644 --- a/skills/java-stellar-sdk/references/xdr_scval.md +++ b/skills/java-stellar-sdk/references/xdr_scval.md @@ -21,6 +21,7 @@ Scv.toUint256(BigInteger.valueOf(7)); Scv.toInt256(BigInteger.valueOf(-7)); Scv.toTimePoint(BigInteger.valueOf(1700000000)); Scv.toDuration(BigInteger.valueOf(3600)); Scv.toBytes(new byte[] {1, 2}); Scv.toString("hello"); Scv.toSymbol("increment"); +Scv.toExecutableTag("v1"); // CAP-85 tag; byte[] overload for a binary tag Scv.toAddress("G..."); // account or contract ("C...") address Scv.toVec(List.of(Scv.toUint32(1L), Scv.toUint32(2L))); Scv.toMap(Map.of(Scv.toSymbol("k"), Scv.toUint32(1L))); @@ -31,6 +32,7 @@ Scv.toMap(Map.of(Scv.toSymbol("k"), Scv.toUint32(1L))); Scv.fromUint32(v); // -> long Scv.fromInt128(v); // -> BigInteger Scv.fromString(v); // -> byte[]; new String(bytes, StandardCharsets.UTF_8) for text +Scv.fromExecutableTag(v); // -> byte[]; Util.decodeUtf8(bytes) for text, raw bytes if not UTF-8 Scv.fromSymbol(v); // -> String Scv.fromAddress(v); // -> Address Scv.fromVec(v); // -> Collection diff --git a/src/main/java/org/stellar/sdk/SorobanServer.java b/src/main/java/org/stellar/sdk/SorobanServer.java index d0f0fe1cf..bd4881d72 100644 --- a/src/main/java/org/stellar/sdk/SorobanServer.java +++ b/src/main/java/org/stellar/sdk/SorobanServer.java @@ -28,6 +28,7 @@ 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.ExternalRefNotFoundException; import org.stellar.sdk.contract.exception.StellarAssetContractHasNoWasmException; import org.stellar.sdk.exception.AccountNotFoundException; import org.stellar.sdk.exception.ConnectionErrorException; @@ -64,11 +65,14 @@ import org.stellar.sdk.xdr.ContractCodeEntry; import org.stellar.sdk.xdr.ContractDataDurability; import org.stellar.sdk.xdr.ContractExecutable; +import org.stellar.sdk.xdr.ContractExecutableExternalRef; 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.SCAddress; +import org.stellar.sdk.xdr.SCAddressType; import org.stellar.sdk.xdr.SCContractInstance; import org.stellar.sdk.xdr.SCVal; import org.stellar.sdk.xdr.SCValType; @@ -765,22 +769,125 @@ public GetSACBalanceResponse getSACBalance(String contractId, Asset asset, Netwo .build(); } + /** + * Resolves a CAP-85 external + * executable reference to the Wasm hash it names. + * + *

A contract created from an external reference does not carry its own Wasm hash. Instead the + * reference names an owner contract and a tag, and the owner holds a persistent contract + * data entry keyed by that tag whose value is the 32-byte hash of an existing Wasm. This performs + * exactly that lookup; the owner contract is not invoked. + * + *

Example: + * + *

{@code
+   * ContractExecutable executable = instance.getExecutable();
+   * if (executable.getDiscriminant() == ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) {
+   *   byte[] wasmHash = server.getExternalRefWasmHash(executable.getExternal_ref());
+   *   byte[] wasm = server.getContractWasmByHash(wasmHash);
+   * }
+   * }
+ * + * @param externalRef The external executable reference, for example the {@code external_ref} arm + * of a contract instance's executable. + * @return The 32-byte Wasm hash the reference resolves to. + * @throws IllegalArgumentException If {@code externalRef} is null or structurally incomplete, or + * its owner is not a contract and so cannot hold the tag entry that names the Wasm. + * @throws ExternalRefNotFoundException If the tag entry does not exist or has been archived. + * @throws ContractWasmRetrievalException If the tag entry does not hold a 32-byte Wasm hash, or + * the RPC response contains unexpected ledger entry data. + * @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 + * connectivity problems, etc. + */ + public byte[] getExternalRefWasmHash(ContractExecutableExternalRef externalRef) { + if (externalRef == null) { + throw new IllegalArgumentException("externalRef must not be null"); + } + + SCAddress ownerAddress = externalRef.getExecutable_owner(); + if (ownerAddress == null) { + throw new IllegalArgumentException("externalRef is missing its owner"); + } + if (ownerAddress.getDiscriminant() == null) { + throw new IllegalArgumentException("externalRef owner is missing its address type"); + } + if (ownerAddress.getDiscriminant() != SCAddressType.SC_ADDRESS_TYPE_CONTRACT) { + // Only a contract can hold the persistent tag entry that names the Wasm, so any other owner + // is unresolvable; fail before spending a request on it. + throw new IllegalArgumentException( + "External executable owner " + + describeExternalRefOwner(ownerAddress) + + " is not a contract, so it cannot hold the tag entry that names the Wasm"); + } + if (ownerAddress.getContractId() == null) { + throw new IllegalArgumentException("externalRef owner is missing its contract ID"); + } + if (externalRef.getTag() == null + || externalRef.getTag().getSCString() == null + || externalRef.getTag().getSCString().getBytes() == null) { + throw new IllegalArgumentException("externalRef is missing its tag"); + } + String owner = Address.fromSCAddress(ownerAddress).toString(); + byte[] tag = externalRef.getTag().getSCString().getBytes(); + + // The tag is an unbounded SCString and may be binary, so it is reused as-is rather than + // decoded; a lenient decode would build the key of a different entry. + Optional entry = + getContractData(owner, Scv.toExecutableTag(tag), Durability.PERSISTENT); + if (!entry.isPresent()) { + throw new ExternalRefNotFoundException(owner, tag); + } + + LedgerEntry.LedgerEntryData ledgerEntryData = + parseLedgerEntryData( + entry.get().getXdr(), + "Failed to parse external executable tag ledger entry, owner: " + owner); + if (ledgerEntryData.getDiscriminant() != LedgerEntryType.CONTRACT_DATA + || ledgerEntryData.getContractData() == null) { + throw new ContractWasmRetrievalException( + "Unexpected ledger entry type for external executable tag entry, owner: " + owner); + } + + SCVal value = ledgerEntryData.getContractData().getVal(); + if (value == null + || value.getDiscriminant() != SCValType.SCV_BYTES + || value.getBytes() == null + || value.getBytes().getSCBytes() == null + || value.getBytes().getSCBytes().length != 32) { + throw new ContractWasmRetrievalException( + "External executable tag entry on " + owner + " does not hold a 32-byte Wasm hash"); + } + return value.getBytes().getSCBytes(); + } + /** * 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. * + *

A contract created from a CAP-85 external executable reference carries no Wasm hash of its own, so + * its reference is resolved to one first (see {@link #getExternalRefWasmHash( + * ContractExecutableExternalRef)}) at the cost of one extra request. + * * @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. * @throws StellarAssetContractHasNoWasmException If the contract is a Stellar Asset Contract, * which has no Wasm. + * @throws ExternalRefNotFoundException If the contract follows an external executable reference + * whose tag entry does not exist or has been archived. * @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. + * data, including an external executable reference the instance holds that cannot be + * resolved, such as one whose owner is not a contract. * @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. @@ -840,6 +947,28 @@ public byte[] getContractWasm(String contractId) { if (type == ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) { throw new StellarAssetContractHasNoWasmException(contractId); } + if (type == ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) { + // A CAP-85 reference names its code indirectly; resolve the tag entry on the owner contract + // to get the hash it currently points at, then proceed as for CONTRACT_EXECUTABLE_WASM. + ContractExecutableExternalRef externalRef = executable.getExternal_ref(); + if (externalRef == null) { + throw new ContractWasmRetrievalException( + "Contract instance is missing its external executable reference, contractId: " + + contractId); + } + byte[] wasmHash; + try { + wasmHash = getExternalRefWasmHash(externalRef); + } catch (IllegalArgumentException e) { + // The reference came off the ledger rather than from the caller, so an unusable one is + // unexpected response data and belongs in the introspection exception hierarchy. + throw new ContractWasmRetrievalException( + "Contract instance holds an unusable external executable reference, contractId: " + + contractId, + e); + } + return getContractWasmByHash(wasmHash); + } if (type != ContractExecutableType.CONTRACT_EXECUTABLE_WASM) { throw new ContractWasmRetrievalException( "Unsupported contract executable type: " + type + ", contractId: " + contractId); @@ -955,6 +1084,21 @@ public ContractInfo getContractInfo(String contractId) { return ContractInfo.fromWasm(getContractWasm(contractId)); } + /** + * Renders an external executable owner for an error message, without assuming the address is + * well-formed -- the reference may have been built by hand. + */ + private static String describeExternalRefOwner(SCAddress ownerAddress) { + if (ownerAddress == null || ownerAddress.getDiscriminant() == null) { + return "(missing)"; + } + try { + return Address.fromSCAddress(ownerAddress).toString(); + } catch (RuntimeException e) { + return "(" + ownerAddress.getDiscriminant() + ")"; + } + } + private static LedgerEntry.LedgerEntryData parseLedgerEntryData(String xdr, String errorContext) { if (xdr == null || xdr.isEmpty()) { throw new ContractWasmRetrievalException(errorContext + ": empty XDR payload"); diff --git a/src/main/java/org/stellar/sdk/Util.java b/src/main/java/org/stellar/sdk/Util.java index 8c2d3d2d6..1f4716391 100644 --- a/src/main/java/org/stellar/sdk/Util.java +++ b/src/main/java/org/stellar/sdk/Util.java @@ -2,10 +2,14 @@ import java.io.IOException; import java.math.BigInteger; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Arrays; +import java.util.Optional; import org.stellar.sdk.exception.UnexpectedException; /** @@ -177,6 +181,33 @@ public static int compareBytesUnsigned(byte[] a, byte[] b) { return Integer.compare(a.length, b.length); } + /** + * Decodes {@code bytes} as UTF-8 text, returning empty when they are not valid UTF-8. + * + *

Unlike {@code new String(bytes, UTF_8)} (and {@link + * org.stellar.sdk.xdr.XdrString#toString()}), this never substitutes {@code U+FFFD} for malformed + * input, so distinct byte strings never decode to the same text. Use it for values that are byte + * strings carrying a hint of text rather than text proper — an {@code SCV_STRING} payload, or a + * CAP-85 executable tag — and + * show the raw bytes when it returns empty. + * + * @param bytes the bytes to decode + * @return the decoded text, or {@link Optional#empty()} if {@code bytes} are not valid UTF-8 + */ + public static Optional decodeUtf8(byte[] bytes) { + try { + return Optional.of( + StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString()); + } catch (CharacterCodingException e) { + return Optional.empty(); + } + } + /** The function that converts XDR string to XDR object. */ @FunctionalInterface public interface XdrDecodeFunction { diff --git a/src/main/java/org/stellar/sdk/contract/exception/ExternalRefNotFoundException.java b/src/main/java/org/stellar/sdk/contract/exception/ExternalRefNotFoundException.java new file mode 100644 index 000000000..217949cc1 --- /dev/null +++ b/src/main/java/org/stellar/sdk/contract/exception/ExternalRefNotFoundException.java @@ -0,0 +1,41 @@ +package org.stellar.sdk.contract.exception; + +import lombok.Getter; +import org.stellar.sdk.Util; + +/** + * Raised when the CAP-85 + * executable tag entry that an external executable reference points at cannot be found on the + * network. The entry may have been archived; restoring the tag entry footprint may be required. + */ +public class ExternalRefNotFoundException extends ContractIntrospectionException { + @Getter private final String owner; + private final byte[] tag; + + public ExternalRefNotFoundException(String owner, byte[] tag) { + super( + "External executable tag entry not found or archived. The tag entry footprint may need to be restored. owner: " + + owner + + ", tag: " + + describeTag(tag)); + this.owner = owner; + this.tag = tag == null ? null : tag.clone(); + } + + /** Returns a defensive copy of the tag that was looked up. */ + public byte[] getTag() { + return tag == null ? null : tag.clone(); + } + + /** + * Renders the tag for the message. A tag is an unbounded {@code SCString} that need not be valid + * UTF-8, so a binary tag is shown as hex rather than lenient-decoded into text that no longer + * identifies it. + */ + private static String describeTag(byte[] tag) { + if (tag == null) { + return "null"; + } + return Util.decodeUtf8(tag).orElseGet(() -> "0x" + Util.bytesToHex(tag)); + } +} diff --git a/src/main/java/org/stellar/sdk/operations/InvokeHostFunctionOperation.java b/src/main/java/org/stellar/sdk/operations/InvokeHostFunctionOperation.java index ac60b9db8..c7119242f 100644 --- a/src/main/java/org/stellar/sdk/operations/InvokeHostFunctionOperation.java +++ b/src/main/java/org/stellar/sdk/operations/InvokeHostFunctionOperation.java @@ -1,5 +1,6 @@ package org.stellar.sdk.operations; +import java.nio.charset.StandardCharsets; import java.security.SecureRandom; import java.util.ArrayList; import java.util.Arrays; @@ -17,6 +18,7 @@ import org.stellar.sdk.Asset; import org.stellar.sdk.Util; import org.stellar.sdk.xdr.ContractExecutable; +import org.stellar.sdk.xdr.ContractExecutableExternalRef; import org.stellar.sdk.xdr.ContractExecutableType; import org.stellar.sdk.xdr.ContractIDPreimage; import org.stellar.sdk.xdr.ContractIDPreimageType; @@ -28,6 +30,7 @@ import org.stellar.sdk.xdr.InvokeContractArgs; import org.stellar.sdk.xdr.InvokeHostFunctionOp; import org.stellar.sdk.xdr.OperationType; +import org.stellar.sdk.xdr.SCString; import org.stellar.sdk.xdr.SCSymbol; import org.stellar.sdk.xdr.SCVal; import org.stellar.sdk.xdr.SorobanAuthorizationEntry; @@ -123,6 +126,108 @@ public static InvokeHostFunctionOperation fromXdr(InvokeHostFunctionOp op) { Address address, @Nullable Collection constructorArgs, @Nullable byte[] salt) { + if (wasmId.length != 32) { + throw new IllegalArgumentException("\"wasmId\" must be 32 bytes long"); + } + + ContractExecutable executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) + .wasm_hash(new Hash(wasmId)) + .build(); + return createContractOperationBuilder(executable, address, constructorArgs, salt); + } + + /** + * This function will create an {@link InvokeHostFunctionOperationBuilder} with the "hostFunction" + * parameter preset, so that you can conveniently build an {@link InvokeHostFunctionOperation} to + * create a contract from a CAP-85 external executable reference instead of from an uploaded Wasm hash. + * + *

The reference names an owner contract and a tag; the owner publishes the Wasm hash under + * that tag, and the created contract follows it, so the owner can upgrade every contract that + * references the tag at once. + * + * @param owner The contract that owns the executable. Only a contract can hold the persistent tag + * entry that names the Wasm, so this must be a contract address. + * @param tag The owner-scoped tag naming the executable, encoded as UTF-8. Use {@link + * #createContractFromExternalRefOperationBuilder(Address, byte[], Address, Collection, + * byte[])} for a tag that is not text. + * @param address The address to use to derive the contract ID. + * @param constructorArgs The optional parameters to pass to the constructor of this contract. + * @param salt The 32-byte salt to use to derive the contract ID, if null, a random salt will be + * generated. + * @return {@link InvokeHostFunctionOperationBuilder} + */ + public static InvokeHostFunctionOperationBuilder + createContractFromExternalRefOperationBuilder( + Address owner, + String tag, + Address address, + @Nullable Collection constructorArgs, + @Nullable byte[] salt) { + return createContractFromExternalRefOperationBuilder( + owner, tag.getBytes(StandardCharsets.UTF_8), address, constructorArgs, salt); + } + + /** + * This function will create an {@link InvokeHostFunctionOperationBuilder} with the "hostFunction" + * parameter preset, so that you can conveniently build an {@link InvokeHostFunctionOperation} to + * create a contract from a CAP-85 external executable reference instead of from an uploaded Wasm hash. + * + *

See {@link #createContractFromExternalRefOperationBuilder(Address, String, Address, + * Collection, byte[])} for what the reference names. A tag is an unbounded {@code SCString} and + * need not be valid UTF-8, so binary tags are passed through here undecoded. + * + * @param owner The contract that owns the executable. Only a contract can hold the persistent tag + * entry that names the Wasm, so this must be a contract address. + * @param tag The owner-scoped tag naming the executable. + * @param address The address to use to derive the contract ID. + * @param constructorArgs The optional parameters to pass to the constructor of this contract. + * @param salt The 32-byte salt to use to derive the contract ID, if null, a random salt will be + * generated. + * @return {@link InvokeHostFunctionOperationBuilder} + */ + public static InvokeHostFunctionOperationBuilder + createContractFromExternalRefOperationBuilder( + Address owner, + byte[] tag, + Address address, + @Nullable Collection constructorArgs, + @Nullable byte[] salt) { + // Only a contract can hold the persistent tag entry that names the Wasm, so any other owner is + // unresolvable and the deploy would fail on-chain. + if (owner.getAddressType() != Address.AddressType.CONTRACT) { + throw new IllegalArgumentException("\"owner\" must be a contract address"); + } + // Caught here rather than at encode time, where it would surface as a NullPointerException + // from deep inside XDR serialization. + if (tag == null) { + throw new IllegalArgumentException("\"tag\" must not be null"); + } + + ContractExecutable executable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) + .external_ref( + ContractExecutableExternalRef.builder() + .executable_owner(owner.toSCAddress()) + .tag(new SCString(new XdrString(tag))) + .build()) + .build(); + return createContractOperationBuilder(executable, address, constructorArgs, salt); + } + + /** + * Builds a {@code CREATE_CONTRACT_V2} host function that deploys {@code executable} to the + * contract ID derived from {@code address} and {@code salt}. + */ + private static InvokeHostFunctionOperationBuilder createContractOperationBuilder( + ContractExecutable executable, + Address address, + @Nullable Collection constructorArgs, + @Nullable byte[] salt) { if (salt == null) { salt = new byte[32]; new SecureRandom().nextBytes(salt); @@ -130,10 +235,6 @@ public static InvokeHostFunctionOperation fromXdr(InvokeHostFunctionOp op) { throw new IllegalArgumentException("\"salt\" must be 32 bytes long"); } - if (wasmId.length != 32) { - throw new IllegalArgumentException("\"wasmId\" must be 32 bytes long"); - } - CreateContractArgsV2 createContractArgs = CreateContractArgsV2.builder() .contractIDPreimage( @@ -145,11 +246,7 @@ public static InvokeHostFunctionOperation fromXdr(InvokeHostFunctionOp op) { .salt(new Uint256(salt)) .build()) .build()) - .executable( - ContractExecutable.builder() - .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) - .wasm_hash(new Hash(wasmId)) - .build()) + .executable(executable) .constructorArgs( constructorArgs != null ? constructorArgs.toArray(new SCVal[0]) : new SCVal[0]) .build(); diff --git a/src/main/java/org/stellar/sdk/scval/Scv.java b/src/main/java/org/stellar/sdk/scval/Scv.java index 11d45eb1a..e555f6554 100644 --- a/src/main/java/org/stellar/sdk/scval/Scv.java +++ b/src/main/java/org/stellar/sdk/scval/Scv.java @@ -1,6 +1,7 @@ package org.stellar.sdk.scval; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; @@ -147,6 +148,52 @@ public static SCError fromError(SCVal scVal) { return ScvError.fromSCVal(scVal); } + /** + * Build a {@link SCVal} with the type of {@link SCValType#SCV_EXECUTABLE_TAG}. + * + *

A CAP-85 executable tag is + * the owner-scoped name of an executable. Wrapped in an {@link SCValType#SCV_EXECUTABLE_TAG} + * {@link SCVal}, it is the key of the persistent contract data entry on the owner contract that + * holds the Wasm hash the tag currently names. + * + * @param tag tag to convert, encoded as UTF-8 + * @return {@link SCVal} with the type of {@link SCValType#SCV_EXECUTABLE_TAG} + * @see org.stellar.sdk.SorobanServer#getExternalRefWasmHash( + * org.stellar.sdk.xdr.ContractExecutableExternalRef) + */ + public static SCVal toExecutableTag(String tag) { + return ScvExecutableTag.toSCVal(tag.getBytes(StandardCharsets.UTF_8)); + } + + /** + * Build a {@link SCVal} with the type of {@link SCValType#SCV_EXECUTABLE_TAG}. + * + *

A tag is an unbounded {@code SCString} and need not be valid UTF-8, so a binary tag is + * passed through here undecoded. See {@link #toExecutableTag(String)} for what the tag names. + * + * @param tag tag to convert + * @return {@link SCVal} with the type of {@link SCValType#SCV_EXECUTABLE_TAG} + */ + public static SCVal toExecutableTag(byte[] tag) { + return ScvExecutableTag.toSCVal(tag); + } + + /** + * Convert from {@link SCVal} with the type of {@link SCValType#SCV_EXECUTABLE_TAG} to byte[]. + * + *

The raw bytes are returned rather than a decoded string: a tag is an unbounded {@code + * SCString} that need not be valid UTF-8, and it is half of what identifies the code being + * deployed, so a lenient decode would render two distinct tags identically. To display a tag, + * pass these bytes to {@link org.stellar.sdk.Util#decodeUtf8(byte[])} and fall back to the raw + * bytes when it returns empty. + * + * @param scVal {@link SCVal} to convert + * @return the tag value in bytes + */ + public static byte[] fromExecutableTag(SCVal scVal) { + return ScvExecutableTag.fromSCVal(scVal); + } + /** * Build a {@link SCVal} with the type of {@link SCValType#SCV_I128}. * diff --git a/src/main/java/org/stellar/sdk/scval/ScvComparator.java b/src/main/java/org/stellar/sdk/scval/ScvComparator.java index 2b504a787..a0b7de5b2 100644 --- a/src/main/java/org/stellar/sdk/scval/ScvComparator.java +++ b/src/main/java/org/stellar/sdk/scval/ScvComparator.java @@ -19,7 +19,7 @@ * *

    *
  1. Cross-type: compare by {@link SCValType} discriminant value ({@code SCV_BOOL=0 < - * SCV_VOID=1 < ... < SCV_LEDGER_KEY_NONCE=21}). + * SCV_VOID=1 < ... < SCV_EXECUTABLE_TAG=22}). *
  2. Same-type (by variant): *
      *
    • {@code SCV_BOOL}: {@code False (0) < True (1)} @@ -32,12 +32,13 @@ * unsigned) *
    • {@code SCV_I256}: tuple comparison {@code (hi_hi, hi_lo, lo_hi, lo_lo)} (hi_hi * signed) - *
    • {@code SCV_BYTES / STRING / SYMBOL}: lexicographic byte comparison + *
    • {@code SCV_BYTES / STRING / SYMBOL / EXECUTABLE_TAG}: lexicographic byte comparison *
    • {@code SCV_VEC}: element-by-element, shorter < longer *
    • {@code SCV_MAP}: entry-by-entry (key first, then val), shorter < longer *
    • {@code SCV_ADDRESS}: by address type discriminant, then structurally per variant *
    • {@code SCV_ERROR}: by error type discriminant, then contract_code or error code - *
    • {@code SCV_CONTRACT_INSTANCE}: by executable type, then wasm_hash, then storage + *
    • {@code SCV_CONTRACT_INSTANCE}: by executable type, then wasm_hash or external ref + * (owner, then tag), then storage *
    • {@code SCV_LEDGER_KEY_NONCE}: signed numeric comparison of nonce *
    *
@@ -212,6 +213,10 @@ static int compareScVal(SCVal a, SCVal b) { case SCV_LEDGER_KEY_NONCE: return Long.compare( a.getNonce_key().getNonce().getInt64(), b.getNonce_key().getNonce().getInt64()); + case SCV_EXECUTABLE_TAG: + return Util.compareBytesUnsigned( + a.getExecutable_tag().getSCString().getBytes(), + b.getExecutable_tag().getSCString().getBytes()); default: throw new IllegalArgumentException("Unsupported SCVal type: " + t); } @@ -271,6 +276,17 @@ static int compareContractExecutable(ContractExecutable a, ContractExecutable b) return Util.compareBytesUnsigned(a.getWasm_hash().getHash(), b.getWasm_hash().getHash()); case CONTRACT_EXECUTABLE_STELLAR_ASSET: return 0; + case CONTRACT_EXECUTABLE_EXTERNAL_REF: + { + cmp = + compareScAddress( + a.getExternal_ref().getExecutable_owner(), + b.getExternal_ref().getExecutable_owner()); + if (cmp != 0) return cmp; + return Util.compareBytesUnsigned( + a.getExternal_ref().getTag().getSCString().getBytes(), + b.getExternal_ref().getTag().getSCString().getBytes()); + } default: throw new IllegalArgumentException( "Unsupported ContractExecutable type: " + a.getDiscriminant()); diff --git a/src/main/java/org/stellar/sdk/scval/ScvExecutableTag.java b/src/main/java/org/stellar/sdk/scval/ScvExecutableTag.java new file mode 100644 index 000000000..4bf815827 --- /dev/null +++ b/src/main/java/org/stellar/sdk/scval/ScvExecutableTag.java @@ -0,0 +1,28 @@ +package org.stellar.sdk.scval; + +import org.stellar.sdk.xdr.SCString; +import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SCValType; +import org.stellar.sdk.xdr.XdrString; + +/** Represents an {@link SCVal} with the type of {@link SCValType#SCV_EXECUTABLE_TAG}. */ +class ScvExecutableTag { + private static final SCValType TYPE = SCValType.SCV_EXECUTABLE_TAG; + + static SCVal toSCVal(byte[] value) { + return SCVal.builder() + .discriminant(TYPE) + .executable_tag(new SCString(new XdrString(value))) + .build(); + } + + static byte[] fromSCVal(SCVal scVal) { + if (scVal.getDiscriminant() != TYPE) { + throw new IllegalArgumentException( + String.format( + "invalid scVal type, expected %s, but got %s", TYPE, scVal.getDiscriminant())); + } + + return scVal.getExecutable_tag().getSCString().getBytes(); + } +} diff --git a/src/test/java/org/stellar/sdk/operations/InvokeHostFunctionOperationTest.java b/src/test/java/org/stellar/sdk/operations/InvokeHostFunctionOperationTest.java index 6ae33dbde..3a6b31822 100644 --- a/src/test/java/org/stellar/sdk/operations/InvokeHostFunctionOperationTest.java +++ b/src/test/java/org/stellar/sdk/operations/InvokeHostFunctionOperationTest.java @@ -1,11 +1,15 @@ package org.stellar.sdk.operations; +import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotEquals; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -16,6 +20,7 @@ import org.stellar.sdk.Util; import org.stellar.sdk.scval.Scv; import org.stellar.sdk.xdr.ContractExecutable; +import org.stellar.sdk.xdr.ContractExecutableExternalRef; import org.stellar.sdk.xdr.ContractExecutableType; import org.stellar.sdk.xdr.ContractIDPreimage; import org.stellar.sdk.xdr.ContractIDPreimageType; @@ -27,6 +32,7 @@ import org.stellar.sdk.xdr.Int64; import org.stellar.sdk.xdr.InvokeContractArgs; import org.stellar.sdk.xdr.SCAddress; +import org.stellar.sdk.xdr.SCString; import org.stellar.sdk.xdr.SCSymbol; import org.stellar.sdk.xdr.SCVal; import org.stellar.sdk.xdr.SorobanAddressCredentials; @@ -42,6 +48,16 @@ import org.stellar.sdk.xdr.XdrUnsignedInteger; public class InvokeHostFunctionOperationTest { + private static final String OWNER_CONTRACT_ID = + "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA"; + + private static final byte[] SALT = + new byte[] { + 0x11, 0x33, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, + 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, + 0x1e, 0x1f + }; + CreateContractArgs createContractArgs = CreateContractArgs.builder() .contractIDPreimage( @@ -469,6 +485,177 @@ public void createContractOperationBuilderWithConstructorArgs() { assertEquals(expectedXdr, operation.toXdrBase64()); } + @Test + public void createContractFromExternalRefOperationBuilderWithTextTag() { + Address owner = new Address(OWNER_CONTRACT_ID); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + InvokeHostFunctionOperation operation = + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, "my-executable", address, null, SALT) + .build(); + + HostFunction expectedFunction = + externalRefHostFunction( + owner, "my-executable".getBytes(StandardCharsets.UTF_8), address, SALT, new SCVal[0]); + assertEquals(expectedFunction, operation.getHostFunction()); + assertTrue(operation.getAuth().isEmpty()); + assertNull(operation.getSourceAccount()); + assertEquals(operation, Operation.fromXdr(operation.toXdr())); + } + + @Test + public void createContractFromExternalRefOperationBuilderPassesBinaryTagThroughUndecoded() { + // A tag is an unbounded SCString and need not be UTF-8; a lenient decode would name different + // code. + byte[] tag = new byte[] {(byte) 0xff, (byte) 0xfe, 0x00, 0x41}; + Address owner = new Address(OWNER_CONTRACT_ID); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + InvokeHostFunctionOperation operation = + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, tag, address, null, SALT) + .build(); + + assertEquals( + externalRefHostFunction(owner, tag, address, SALT, new SCVal[0]), + operation.getHostFunction()); + assertArrayEquals( + tag, + operation + .getHostFunction() + .getCreateContractV2() + .getExecutable() + .getExternal_ref() + .getTag() + .getSCString() + .getBytes()); + assertEquals(operation, Operation.fromXdr(operation.toXdr())); + } + + @Test + public void createContractFromExternalRefOperationBuilderWithConstructorArgs() { + Address owner = new Address(OWNER_CONTRACT_ID); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + List constructorArgs = + Arrays.asList( + Scv.toAddress("GA2KQTETIRREL66P64GV6KCVICPULLDVHWJDZSIJKDLIAGBXUCIZ6P6E"), + Scv.toUint64(BigInteger.valueOf(123456789L))); + InvokeHostFunctionOperation operation = + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, "v1", address, constructorArgs, SALT) + .build(); + + assertEquals( + externalRefHostFunction( + owner, + "v1".getBytes(StandardCharsets.UTF_8), + address, + SALT, + constructorArgs.toArray(new SCVal[0])), + operation.getHostFunction()); + assertEquals(operation, Operation.fromXdr(operation.toXdr())); + } + + @Test + public void createContractFromExternalRefOperationBuilderGeneratesRandomSaltWhenNull() { + Address owner = new Address(OWNER_CONTRACT_ID); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + byte[] first = + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, "v1", address, null, null) + .build() + .getHostFunction() + .getCreateContractV2() + .getContractIDPreimage() + .getFromAddress() + .getSalt() + .getUint256(); + byte[] second = + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, "v1", address, null, null) + .build() + .getHostFunction() + .getCreateContractV2() + .getContractIDPreimage() + .getFromAddress() + .getSalt() + .getUint256(); + + assertEquals(32, first.length); + assertFalse(Arrays.equals(first, second)); + } + + @Test + public void createContractFromExternalRefOperationBuilderRejectsInvalidSalt() { + Address owner = new Address(OWNER_CONTRACT_ID); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, "v1", address, null, new byte[31])); + assertEquals("\"salt\" must be 32 bytes long", e.getMessage()); + } + + @Test + public void createContractFromExternalRefOperationBuilderRejectsNullTag() { + // Caught at the builder rather than deferred to XDR serialization. + Address owner = new Address(OWNER_CONTRACT_ID); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, (byte[]) null, address, null, SALT)); + assertEquals("\"tag\" must not be null", e.getMessage()); + } + + @Test + public void createContractFromExternalRefOperationBuilderRejectsNonContractOwner() { + // Only a contract can hold the persistent tag entry that names the Wasm, so this fails here + // rather than on-chain. + Address owner = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + Address address = new Address("GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX"); + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + InvokeHostFunctionOperation.createContractFromExternalRefOperationBuilder( + owner, "v1", address, null, SALT)); + assertEquals("\"owner\" must be a contract address", e.getMessage()); + } + + private static HostFunction externalRefHostFunction( + Address owner, byte[] tag, Address address, byte[] salt, SCVal[] constructorArgs) { + CreateContractArgsV2 createContractArgs = + CreateContractArgsV2.builder() + .contractIDPreimage( + ContractIDPreimage.builder() + .discriminant(ContractIDPreimageType.CONTRACT_ID_PREIMAGE_FROM_ADDRESS) + .fromAddress( + ContractIDPreimage.ContractIDPreimageFromAddress.builder() + .address(address.toSCAddress()) + .salt(new Uint256(salt)) + .build()) + .build()) + .executable( + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) + .external_ref( + ContractExecutableExternalRef.builder() + .executable_owner(owner.toSCAddress()) + .tag(new SCString(new XdrString(tag))) + .build()) + .build()) + .constructorArgs(constructorArgs) + .build(); + return HostFunction.builder() + .discriminant(HostFunctionType.HOST_FUNCTION_TYPE_CREATE_CONTRACT_V2) + .createContractV2(createContractArgs) + .build(); + } + @Test public void createStellarAssetContractOperationBuilderWithAsset() { Asset asset = diff --git a/src/test/java/org/stellar/sdk/scval/ScvExecutableTagTest.java b/src/test/java/org/stellar/sdk/scval/ScvExecutableTagTest.java new file mode 100644 index 000000000..d4e1cda1d --- /dev/null +++ b/src/test/java/org/stellar/sdk/scval/ScvExecutableTagTest.java @@ -0,0 +1,69 @@ +package org.stellar.sdk.scval; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import java.nio.charset.StandardCharsets; +import org.junit.Test; +import org.stellar.sdk.xdr.SCString; +import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SCValType; +import org.stellar.sdk.xdr.XdrString; + +public class ScvExecutableTagTest { + @Test + public void testScvExecutableTagFromString() { + String value = "v1"; + + SCVal expectedScVal = + SCVal.builder() + .discriminant(SCValType.SCV_EXECUTABLE_TAG) + .executable_tag(new SCString(new XdrString(value))) + .build(); + + SCVal actualScVal = Scv.toExecutableTag(value); + assertEquals(expectedScVal, actualScVal); + assertArrayEquals(value.getBytes(StandardCharsets.UTF_8), Scv.fromExecutableTag(actualScVal)); + } + + @Test + public void testScvExecutableTagFromStringEncodesUtf8() { + // A non-ASCII tag must go over the wire as UTF-8 regardless of the platform default charset. + String value = "标签"; + + assertArrayEquals( + value.getBytes(StandardCharsets.UTF_8), Scv.fromExecutableTag(Scv.toExecutableTag(value))); + } + + @Test + public void testScvExecutableTagFromBytes() { + // A tag is an unbounded SCString: it need not be valid UTF-8, and it round-trips undecoded. + byte[] value = new byte[] {(byte) 0xff, (byte) 0xfe, 0x00, 0x41}; + + SCVal expectedScVal = + SCVal.builder() + .discriminant(SCValType.SCV_EXECUTABLE_TAG) + .executable_tag(new SCString(new XdrString(value))) + .build(); + + SCVal actualScVal = Scv.toExecutableTag(value); + assertEquals(expectedScVal, actualScVal); + assertArrayEquals(value, Scv.fromExecutableTag(actualScVal)); + } + + @Test + public void testScvExecutableTagFromEmptyBytes() { + byte[] value = new byte[] {}; + assertArrayEquals(value, Scv.fromExecutableTag(Scv.toExecutableTag(value))); + } + + @Test + public void testFromExecutableTagInvalidTypeThrows() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, () -> Scv.fromExecutableTag(Scv.toString("v1"))); + assertEquals( + "invalid scVal type, expected SCV_EXECUTABLE_TAG, but got SCV_STRING", e.getMessage()); + } +} diff --git a/src/test/kotlin/org/stellar/sdk/UtilTest.kt b/src/test/kotlin/org/stellar/sdk/UtilTest.kt index 3bceced16..cd977cbde 100644 --- a/src/test/kotlin/org/stellar/sdk/UtilTest.kt +++ b/src/test/kotlin/org/stellar/sdk/UtilTest.kt @@ -5,6 +5,7 @@ import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.ints.shouldBeNegative import io.kotest.matchers.ints.shouldBePositive import io.kotest.matchers.shouldBe +import java.util.Optional class UtilTest : FunSpec({ @@ -124,4 +125,26 @@ class UtilTest : Util.compareBytesUnsigned(byteArrayOf(2), byteArrayOf(1, 2, 3)).shouldBePositive() } } + + context("decodeUtf8") { + test("decodes valid UTF-8, including empty and multi-byte input") { + Util.decodeUtf8(byteArrayOf()) shouldBe Optional.of("") + Util.decodeUtf8("my-executable".toByteArray(Charsets.UTF_8)) shouldBe + Optional.of("my-executable") + Util.decodeUtf8("标签".toByteArray(Charsets.UTF_8)) shouldBe Optional.of("标签") + } + + test("returns empty for bytes that are not valid UTF-8") { + // A lenient decode would turn each of these into U+FFFD and make two distinct byte + // strings indistinguishable. + Util.decodeUtf8(byteArrayOf(0xFF.toByte(), 0xFE.toByte())) shouldBe Optional.empty() + Util.decodeUtf8(byteArrayOf(0xC3.toByte())) shouldBe Optional.empty() // truncated sequence + Util.decodeUtf8(byteArrayOf(0xED.toByte(), 0xA0.toByte(), 0x80.toByte())) shouldBe + Optional.empty() // surrogate half + } + + test("keeps embedded NUL and control bytes, which are valid UTF-8") { + Util.decodeUtf8(byteArrayOf(0x00, 0x41)) shouldBe Optional.of("\u0000A") + } + } }) diff --git a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt index 41245d04f..a87463d4e 100644 --- a/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt +++ b/src/test/kotlin/org/stellar/sdk/contract/SorobanServerContractIntrospectionTest.kt @@ -1,8 +1,11 @@ package org.stellar.sdk.contract +import com.google.gson.JsonParser import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FunSpec import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf import java.io.ByteArrayOutputStream import java.security.MessageDigest import java.util.Optional @@ -15,23 +18,29 @@ 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.ExternalRefNotFoundException 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.ContractExecutableExternalRef 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.LedgerKey +import org.stellar.sdk.xdr.SCAddress +import org.stellar.sdk.xdr.SCAddressType 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.SCString import org.stellar.sdk.xdr.SCVal import org.stellar.sdk.xdr.SCValType import org.stellar.sdk.xdr.XdrString @@ -135,6 +144,59 @@ private fun emptyEntriesJson(): String = """ .trimIndent() +private const val OWNER_ID = "CA7QYNF7SOWQ3GLR2BGMZEHXAVIRZA4KVWLTJJFC7MGXUA74P7UJUWDA" + +private const val ACCOUNT_ID = "GAHJJJKMOKYE4RVPZEWZTKH5FVI4PA3VL7GK2LFNUBSGBV6OJP7TQSLX" + +private fun externalRef(owner: String, tag: ByteArray): ContractExecutableExternalRef = + ContractExecutableExternalRef.builder() + .executable_owner(Address(owner).toSCAddress()) + .tag(SCString(XdrString(tag))) + .build() + +private fun externalRefExecutable(owner: String, tag: ByteArray): ContractExecutable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) + .external_ref(externalRef(owner, tag)) + .build() + +/** The persistent entry on the owner contract that names the Wasm, keyed by the raw tag bytes. */ +private fun tagLedgerKeyXdr(owner: String, tag: ByteArray): String = + LedgerKey.builder() + .discriminant(LedgerEntryType.CONTRACT_DATA) + .contractData( + LedgerKey.LedgerKeyContractData.builder() + .contract(Address(owner).toSCAddress()) + .key(Scv.toExecutableTag(tag)) + .durability(ContractDataDurability.PERSISTENT) + .build() + ) + .build() + .toXdrBase64() + +private fun tagLedgerEntryXdr(owner: String, tag: ByteArray, value: SCVal): String { + val contractData = + ContractDataEntry.builder() + .ext(ExtensionPoint.builder().discriminant(0).build()) + .contract(Address(owner).toSCAddress()) + .key(Scv.toExecutableTag(tag)) + .durability(ContractDataDurability.PERSISTENT) + .`val`(value) + .build() + return LedgerEntry.LedgerEntryData.builder() + .discriminant(LedgerEntryType.CONTRACT_DATA) + .contractData(contractData) + .build() + .toXdrBase64() +} + +private fun requestedKeys(request: RecordedRequest): List = + JsonParser.parseString(request.body.readUtf8()) + .asJsonObject + .getAsJsonObject("params") + .getAsJsonArray("keys") + .map { it.asString } + private fun sequentialDispatcher(instanceJson: String, codeJson: String): Dispatcher = object : Dispatcher() { var call = 0 @@ -310,6 +372,253 @@ class SorobanServerContractIntrospectionTest : } } + test("getExternalRefWasmHash resolves the tag entry on the owner contract") { + val tag = "my-executable".toByteArray(Charsets.UTF_8) + val wasmHash = sha256("wasm".toByteArray()) + mockWebServer.enqueue( + MockResponse() + .setBody(singleEntryJson(tagLedgerEntryXdr(OWNER_ID, tag, Scv.toBytes(wasmHash)))) + ) + mockWebServer.start() + + newServer().use { server -> + server.getExternalRefWasmHash(externalRef(OWNER_ID, tag)) shouldBe wasmHash + } + + // The owner contract is not invoked; a single getLedgerEntries call reads its tag entry. + mockWebServer.requestCount shouldBe 1 + requestedKeys(mockWebServer.takeRequest()) shouldBe listOf(tagLedgerKeyXdr(OWNER_ID, tag)) + } + + test("getExternalRefWasmHash rejects a null reference") { + mockWebServer.start() + newServer().use { server -> + shouldThrow { server.getExternalRefWasmHash(null) } + } + } + + test("getContractWasm follows an external reference: instance, then tag entry, then code") { + val tag = "my-executable".toByteArray(Charsets.UTF_8) + val wasm = buildMinimalWasmWithMeta("rsver", "1.78.0") + val wasmHash = sha256(wasm) + mockWebServer.enqueue( + MockResponse() + .setBody( + singleEntryJson(contractInstanceLedgerEntryXdr(externalRefExecutable(OWNER_ID, tag))) + ) + ) + mockWebServer.enqueue( + MockResponse() + .setBody(singleEntryJson(tagLedgerEntryXdr(OWNER_ID, tag, Scv.toBytes(wasmHash)))) + ) + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(contractCodeLedgerEntryXdr(wasm))) + ) + mockWebServer.start() + + newServer().use { server -> server.getContractWasm(CONTRACT_ID) shouldBe wasm } + + mockWebServer.requestCount shouldBe 3 + mockWebServer.takeRequest() // the contract instance + requestedKeys(mockWebServer.takeRequest()) shouldBe listOf(tagLedgerKeyXdr(OWNER_ID, tag)) + } + + test("getContractSpec follows an external reference") { + val tag = "my-executable".toByteArray(Charsets.UTF_8) + val wasm = buildMinimalWasmWithMeta("sep", "41,40") + mockWebServer.enqueue( + MockResponse() + .setBody( + singleEntryJson(contractInstanceLedgerEntryXdr(externalRefExecutable(OWNER_ID, tag))) + ) + ) + mockWebServer.enqueue( + MockResponse() + .setBody(singleEntryJson(tagLedgerEntryXdr(OWNER_ID, tag, Scv.toBytes(sha256(wasm))))) + ) + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(contractCodeLedgerEntryXdr(wasm))) + ) + mockWebServer.start() + + newServer().use { server -> + server.getContractInfo(CONTRACT_ID).meta.supportedSeps() shouldBe setOf(41, 40) + } + } + + test("keys the lookup on a binary tag without decoding it") { + // A tag is an unbounded SCString and need not be UTF-8. A lenient decode would build the key + // of a different entry. + val binaryTag = byteArrayOf(0xff.toByte(), 0xfe.toByte(), 0x00, 0x41) + mockWebServer.enqueue( + MockResponse() + .setBody( + singleEntryJson( + contractInstanceLedgerEntryXdr(externalRefExecutable(OWNER_ID, binaryTag)) + ) + ) + ) + mockWebServer.enqueue(MockResponse().setBody(emptyEntriesJson())) + mockWebServer.start() + + newServer().use { server -> + shouldThrow { server.getContractWasm(CONTRACT_ID) } + } + + mockWebServer.takeRequest() // the contract instance + requestedKeys(mockWebServer.takeRequest()) shouldBe + listOf(tagLedgerKeyXdr(OWNER_ID, binaryTag)) + } + + test("missing tag entry throws ExternalRefNotFoundException naming the owner and tag") { + val tag = "my-executable".toByteArray(Charsets.UTF_8) + mockWebServer.enqueue(MockResponse().setBody(emptyEntriesJson())) + mockWebServer.start() + + newServer().use { server -> + val e = + shouldThrow { + server.getExternalRefWasmHash(externalRef(OWNER_ID, tag)) + } + e.owner shouldBe OWNER_ID + e.tag shouldBe tag + e.message shouldContain OWNER_ID + e.message shouldContain "my-executable" + } + } + + test("a binary tag is rendered as hex rather than lenient-decoded in the error message") { + val binaryTag = byteArrayOf(0xff.toByte(), 0xfe.toByte()) + mockWebServer.enqueue(MockResponse().setBody(emptyEntriesJson())) + mockWebServer.start() + + newServer().use { server -> + val e = + shouldThrow { + server.getExternalRefWasmHash(externalRef(OWNER_ID, binaryTag)) + } + e.tag shouldBe binaryTag + e.message shouldContain "0xFFFE" + } + } + + test("a non-contract owner is rejected before any lookup") { + // Only a contract can hold the persistent tag entry that names the Wasm. + mockWebServer.enqueue( + MockResponse() + .setBody( + singleEntryJson( + contractInstanceLedgerEntryXdr( + externalRefExecutable(ACCOUNT_ID, "v1".toByteArray(Charsets.UTF_8)) + ) + ) + ) + ) + mockWebServer.start() + + newServer().use { server -> + // The reference came off the ledger, not from the caller, so it is reported as unexpected + // response data rather than as a bad argument -- callers catching + // ContractIntrospectionException must not have this escape as IllegalArgumentException. + val e = shouldThrow { server.getContractWasm(CONTRACT_ID) } + e.message shouldContain "unusable external executable reference" + e.cause.shouldBeInstanceOf() + e.cause!!.message shouldContain "is not a contract" + e.cause!!.message shouldContain ACCOUNT_ID + } + mockWebServer.requestCount shouldBe 1 + } + + test("a structurally broken reference is an invalid argument, not a NullPointerException") { + mockWebServer.start() + newServer().use { server -> + // Every arm of a hand-built reference may be missing. None of these may surface as an NPE. + val ownerlessRef = + ContractExecutableExternalRef.builder().tag(SCString(XdrString("v1"))).build() + shouldThrow { server.getExternalRefWasmHash(ownerlessRef) } + .message shouldContain "missing its owner" + + val ownerWithoutType = + ContractExecutableExternalRef.builder() + .executable_owner(SCAddress()) + .tag(SCString(XdrString("v1"))) + .build() + shouldThrow { server.getExternalRefWasmHash(ownerWithoutType) } + .message shouldContain "missing its address type" + + val contractOwnerWithoutId = + ContractExecutableExternalRef.builder() + .executable_owner( + SCAddress.builder().discriminant(SCAddressType.SC_ADDRESS_TYPE_CONTRACT).build() + ) + .tag(SCString(XdrString("v1"))) + .build() + shouldThrow { + server.getExternalRefWasmHash(contractOwnerWithoutId) + } + .message shouldContain "missing its contract ID" + + val tagless = + ContractExecutableExternalRef.builder() + .executable_owner(Address(OWNER_ID).toSCAddress()) + .build() + shouldThrow { server.getExternalRefWasmHash(tagless) } + + val nullTagBytes = + ContractExecutableExternalRef.builder() + .executable_owner(Address(OWNER_ID).toSCAddress()) + .tag(SCString(XdrString(null as ByteArray?))) + .build() + shouldThrow { server.getExternalRefWasmHash(nullTagBytes) } + } + mockWebServer.requestCount shouldBe 0 + } + + test("tag entry that does not hold a 32-byte hash throws") { + val tag = "v1".toByteArray(Charsets.UTF_8) + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(tagLedgerEntryXdr(OWNER_ID, tag, Scv.toUint32(7)))) + ) + mockWebServer.enqueue( + MockResponse() + .setBody(singleEntryJson(tagLedgerEntryXdr(OWNER_ID, tag, Scv.toBytes(ByteArray(31))))) + ) + mockWebServer.start() + + newServer().use { server -> + val ref = externalRef(OWNER_ID, tag) + val wrongType = + shouldThrow { server.getExternalRefWasmHash(ref) } + wrongType.message shouldContain "32-byte Wasm hash" + // Bytes of the wrong length are rejected too. + shouldThrow { server.getExternalRefWasmHash(ref) } + } + } + + test("tag entry that is not contract data throws") { + mockWebServer.enqueue( + MockResponse().setBody(singleEntryJson(contractCodeLedgerEntryXdr(byteArrayOf(0, 1, 2)))) + ) + mockWebServer.start() + + newServer().use { server -> + shouldThrow { + server.getExternalRefWasmHash(externalRef(OWNER_ID, "v1".toByteArray(Charsets.UTF_8))) + } + } + } + + test("malformed tag entry XDR throws ContractWasmRetrievalException") { + mockWebServer.enqueue(MockResponse().setBody(singleEntryJson("not-valid-base64!@#"))) + mockWebServer.start() + + newServer().use { server -> + shouldThrow { + server.getExternalRefWasmHash(externalRef(OWNER_ID, "v1".toByteArray(Charsets.UTF_8))) + } + } + } + test("getContractInfo parses fetched Wasm") { val wasm = buildMinimalWasmWithMeta("sep", "41,40") val executable = diff --git a/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt b/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt index 125a000b1..e89d69818 100644 --- a/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt +++ b/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt @@ -9,6 +9,7 @@ import org.stellar.sdk.xdr.AccountID import org.stellar.sdk.xdr.ClaimableBalanceID import org.stellar.sdk.xdr.ClaimableBalanceIDType import org.stellar.sdk.xdr.ContractExecutable +import org.stellar.sdk.xdr.ContractExecutableExternalRef import org.stellar.sdk.xdr.ContractExecutableType import org.stellar.sdk.xdr.ContractID import org.stellar.sdk.xdr.Hash @@ -26,11 +27,13 @@ import org.stellar.sdk.xdr.SCErrorType import org.stellar.sdk.xdr.SCMap import org.stellar.sdk.xdr.SCMapEntry import org.stellar.sdk.xdr.SCNonceKey +import org.stellar.sdk.xdr.SCString import org.stellar.sdk.xdr.SCVal import org.stellar.sdk.xdr.SCValType import org.stellar.sdk.xdr.Uint256 import org.stellar.sdk.xdr.Uint32 import org.stellar.sdk.xdr.Uint64 +import org.stellar.sdk.xdr.XdrString import org.stellar.sdk.xdr.XdrUnsignedHyperInteger import org.stellar.sdk.xdr.XdrUnsignedInteger @@ -341,6 +344,24 @@ class ScvComparatorTest : ) shouldBeLessThan 0 } + test("external ref instance sorts after wasm and stellar asset instances") { + val wasm = makeWasmInstance(bytes32(0x00), null) + val asset = makeStellarAssetInstance(null) + val external = makeExternalRefInstance(contractAddress(bytes32(0x00)), "v1", null) + ScvComparator.compareScVal(wasm, external) shouldBeLessThan 0 + ScvComparator.compareScVal(asset, external) shouldBeLessThan 0 + ScvComparator.compareScVal(external, external) shouldBe 0 + } + + test("external ref instances fall back to storage when the ref matches") { + val owner = contractAddress(bytes32(0x00)) + val storage = SCMap(arrayOf(entry(Scv.toUint32(1), Scv.toVoid()))) + ScvComparator.compareScVal( + makeExternalRefInstance(owner, "v1", null), + makeExternalRefInstance(owner, "v1", storage), + ) shouldBeLessThan 0 + } + test("null storage < non-null storage") { val asset = makeStellarAssetInstance(null) val storage = SCMap(arrayOf(entry(Scv.toUint32(1), Scv.toVoid()))) @@ -363,6 +384,38 @@ class ScvComparatorTest : } } + context("SCV_EXECUTABLE_TAG") { + test("lexicographic byte ordering, shorter < longer") { + ScvComparator.compareScVal( + Scv.toExecutableTag("v1"), + Scv.toExecutableTag("v2"), + ) shouldBeLessThan 0 + ScvComparator.compareScVal( + Scv.toExecutableTag("v2"), + Scv.toExecutableTag("v1"), + ) shouldBeGreaterThan 0 + ScvComparator.compareScVal(Scv.toExecutableTag("v1"), Scv.toExecutableTag("v1")) shouldBe 0 + ScvComparator.compareScVal( + Scv.toExecutableTag("v"), + Scv.toExecutableTag("v1"), + ) shouldBeLessThan 0 + } + + test("bytes compare unsigned, so a high byte is greater than an ASCII one") { + ScvComparator.compareScVal( + Scv.toExecutableTag(byteArrayOf(0x41)), + Scv.toExecutableTag(byteArrayOf(0xff.toByte())), + ) shouldBeLessThan 0 + } + + test("sorts after every other type") { + ScvComparator.compareScVal( + Scv.toLedgerKeyNonce(0), + Scv.toExecutableTag("v1"), + ) shouldBeLessThan 0 + } + } + context("compareScAddress") { test("address type ordering: account < contract < muxed < claimable < pool") { val addrs = @@ -444,6 +497,44 @@ class ScvComparatorTest : ScvComparator.compareContractExecutable(wasm, asset) shouldBeLessThan 0 ScvComparator.compareContractExecutable(asset, asset) shouldBe 0 } + + test("STELLAR_ASSET < EXTERNAL_REF") { + val asset = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) + .build() + ScvComparator.compareContractExecutable( + asset, + externalRef(contractAddress(bytes32(0x00)), "v1"), + ) shouldBeLessThan 0 + } + + test("EXTERNAL_REF compares by owner first, then tag") { + val lowOwner = contractAddress(bytes32(0x00)) + val highOwner = contractAddress(bytes32Last(0x01)) + + // A greater tag never outweighs a lesser owner. + ScvComparator.compareContractExecutable( + externalRef(lowOwner, "v9"), + externalRef(highOwner, "v1"), + ) shouldBeLessThan 0 + ScvComparator.compareContractExecutable( + externalRef(lowOwner, "v1"), + externalRef(lowOwner, "v2"), + ) shouldBeLessThan 0 + ScvComparator.compareContractExecutable( + externalRef(lowOwner, "v1"), + externalRef(lowOwner, "v1"), + ) shouldBe 0 + } + + test("EXTERNAL_REF compares a binary tag by unsigned bytes") { + val owner = contractAddress(bytes32(0x00)) + ScvComparator.compareContractExecutable( + externalRef(owner, byteArrayOf(0x41)), + externalRef(owner, byteArrayOf(0xff.toByte())), + ) shouldBeLessThan 0 + } } context("compareOptionalScMap") { @@ -548,6 +639,28 @@ class ScvComparatorTest : ) .build() + private fun externalRef(owner: SCAddress, tag: String): ContractExecutable = + externalRef(owner, tag.toByteArray(Charsets.UTF_8)) + + private fun externalRef(owner: SCAddress, tag: ByteArray): ContractExecutable = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_EXTERNAL_REF) + .external_ref( + ContractExecutableExternalRef.builder() + .executable_owner(owner) + .tag(SCString(XdrString(tag))) + .build() + ) + .build() + + private fun makeExternalRefInstance(owner: SCAddress, tag: String, storage: SCMap?): SCVal = + SCVal.builder() + .discriminant(SCValType.SCV_CONTRACT_INSTANCE) + .instance( + SCContractInstance.builder().executable(externalRef(owner, tag)).storage(storage).build() + ) + .build() + private fun makeStellarAssetInstance(storage: SCMap?): SCVal = SCVal.builder() .discriminant(SCValType.SCV_CONTRACT_INSTANCE)