diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e69bed5..11e20b0bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Pending +- feat: sort `ScMap` entries by key in `Scv.toMap` following Soroban runtime ordering rules, as the network requires ScMap keys to be in ascending order. `Scv.toMap` now accepts `Map`; the previous `toMap(LinkedHashMap)` overload is deprecated. ## 2.2.3 diff --git a/src/main/java/org/stellar/sdk/scval/Scv.java b/src/main/java/org/stellar/sdk/scval/Scv.java index 688b8be53..11d45eb1a 100644 --- a/src/main/java/org/stellar/sdk/scval/Scv.java +++ b/src/main/java/org/stellar/sdk/scval/Scv.java @@ -269,9 +269,27 @@ public static long fromLedgerKeyNonce(SCVal scVal) { /** * Build a {@link SCVal} with the type of {@link SCValType#SCV_MAP}. * + *

The entries are sorted by key following Soroban runtime ordering rules, as the network + * requires ScMap keys to be in ascending order. + * + * @param map map to convert + * @return {@link SCVal} with the type of {@link SCValType#SCV_MAP} + */ + public static SCVal toMap(Map map) { + return ScvMap.toSCVal(map); + } + + /** + * Build a {@link SCVal} with the type of {@link SCValType#SCV_MAP}. + * + *

The entries are sorted by key following Soroban runtime ordering rules, as the network + * requires ScMap keys to be in ascending order. + * * @param map map to convert * @return {@link SCVal} with the type of {@link SCValType#SCV_MAP} + * @deprecated Use {@link #toMap(Map)} instead. */ + @Deprecated public static SCVal toMap(LinkedHashMap map) { return ScvMap.toSCVal(map); } diff --git a/src/main/java/org/stellar/sdk/scval/ScvComparator.java b/src/main/java/org/stellar/sdk/scval/ScvComparator.java new file mode 100644 index 000000000..1af839ab0 --- /dev/null +++ b/src/main/java/org/stellar/sdk/scval/ScvComparator.java @@ -0,0 +1,306 @@ +package org.stellar.sdk.scval; + +import java.util.Comparator; +import org.stellar.sdk.xdr.ContractExecutable; +import org.stellar.sdk.xdr.SCAddress; +import org.stellar.sdk.xdr.SCMap; +import org.stellar.sdk.xdr.SCMapEntry; +import org.stellar.sdk.xdr.SCVal; +import org.stellar.sdk.xdr.SCValType; + +/** + * Comparator for {@link SCVal} values following Soroban runtime ordering rules. + * + *

This mirrors Rust's {@code #[derive(Ord)]} on the {@code ScVal} enum in {@code + * rs-soroban-env}. + * + *

Comparison rules: + * + *

    + *
  1. Cross-type: compare by {@link SCValType} discriminant value ({@code SCV_BOOL=0 < + * SCV_VOID=1 < ... < SCV_LEDGER_KEY_NONCE=21}). + *
  2. Same-type (by variant): + *
      + *
    • {@code SCV_BOOL}: {@code False (0) < True (1)} + *
    • {@code SCV_VOID}, {@code SCV_LEDGER_KEY_CONTRACT_INSTANCE}: always equal + *
    • {@code SCV_U32 / I32 / U64 / I64}: numeric comparison + *
    • {@code SCV_TIMEPOINT / DURATION}: numeric comparison of the underlying uint64 + *
    • {@code SCV_U128}: tuple comparison {@code (hi, lo)} (both unsigned) + *
    • {@code SCV_I128}: tuple comparison {@code (hi, lo)} (hi signed, lo unsigned) + *
    • {@code SCV_U256}: tuple comparison {@code (hi_hi, hi_lo, lo_hi, lo_lo)} (all + * 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_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_LEDGER_KEY_NONCE}: signed numeric comparison of nonce + *
    + *
+ */ +class ScvComparator implements Comparator { + static final ScvComparator INSTANCE = new ScvComparator(); + + @Override + public int compare(SCVal a, SCVal b) { + return compareScVal(a, b); + } + + static int compareScVal(SCVal a, SCVal b) { + if (a.getDiscriminant() != b.getDiscriminant()) { + return Integer.compare(a.getDiscriminant().getValue(), b.getDiscriminant().getValue()); + } + + SCValType t = a.getDiscriminant(); + switch (t) { + case SCV_BOOL: + return Boolean.compare(a.getB(), b.getB()); + case SCV_VOID: + case SCV_LEDGER_KEY_CONTRACT_INSTANCE: + return 0; + case SCV_U32: + return Long.compare(a.getU32().getUint32().getNumber(), b.getU32().getUint32().getNumber()); + case SCV_I32: + return Integer.compare(a.getI32().getInt32(), b.getI32().getInt32()); + case SCV_U64: + return a.getU64().getUint64().getNumber().compareTo(b.getU64().getUint64().getNumber()); + case SCV_I64: + return Long.compare(a.getI64().getInt64(), b.getI64().getInt64()); + case SCV_TIMEPOINT: + return a.getTimepoint() + .getTimePoint() + .getUint64() + .getNumber() + .compareTo(b.getTimepoint().getTimePoint().getUint64().getNumber()); + case SCV_DURATION: + return a.getDuration() + .getDuration() + .getUint64() + .getNumber() + .compareTo(b.getDuration().getDuration().getUint64().getNumber()); + case SCV_U128: + { + int cmp = + a.getU128() + .getHi() + .getUint64() + .getNumber() + .compareTo(b.getU128().getHi().getUint64().getNumber()); + if (cmp != 0) return cmp; + return a.getU128() + .getLo() + .getUint64() + .getNumber() + .compareTo(b.getU128().getLo().getUint64().getNumber()); + } + case SCV_I128: + { + int cmp = Long.compare(a.getI128().getHi().getInt64(), b.getI128().getHi().getInt64()); + if (cmp != 0) return cmp; + return a.getI128() + .getLo() + .getUint64() + .getNumber() + .compareTo(b.getI128().getLo().getUint64().getNumber()); + } + case SCV_U256: + { + int cmp = + a.getU256() + .getHi_hi() + .getUint64() + .getNumber() + .compareTo(b.getU256().getHi_hi().getUint64().getNumber()); + if (cmp != 0) return cmp; + cmp = + a.getU256() + .getHi_lo() + .getUint64() + .getNumber() + .compareTo(b.getU256().getHi_lo().getUint64().getNumber()); + if (cmp != 0) return cmp; + cmp = + a.getU256() + .getLo_hi() + .getUint64() + .getNumber() + .compareTo(b.getU256().getLo_hi().getUint64().getNumber()); + if (cmp != 0) return cmp; + return a.getU256() + .getLo_lo() + .getUint64() + .getNumber() + .compareTo(b.getU256().getLo_lo().getUint64().getNumber()); + } + case SCV_I256: + { + int cmp = + Long.compare(a.getI256().getHi_hi().getInt64(), b.getI256().getHi_hi().getInt64()); + if (cmp != 0) return cmp; + cmp = + a.getI256() + .getHi_lo() + .getUint64() + .getNumber() + .compareTo(b.getI256().getHi_lo().getUint64().getNumber()); + if (cmp != 0) return cmp; + cmp = + a.getI256() + .getLo_hi() + .getUint64() + .getNumber() + .compareTo(b.getI256().getLo_hi().getUint64().getNumber()); + if (cmp != 0) return cmp; + return a.getI256() + .getLo_lo() + .getUint64() + .getNumber() + .compareTo(b.getI256().getLo_lo().getUint64().getNumber()); + } + case SCV_BYTES: + return compareByteArrays(a.getBytes().getSCBytes(), b.getBytes().getSCBytes()); + case SCV_STRING: + return compareByteArrays( + a.getStr().getSCString().getBytes(), b.getStr().getSCString().getBytes()); + case SCV_SYMBOL: + return compareByteArrays( + a.getSym().getSCSymbol().getBytes(), b.getSym().getSCSymbol().getBytes()); + case SCV_VEC: + { + SCVal[] av = a.getVec().getSCVec(); + SCVal[] bv = b.getVec().getSCVec(); + int len = Math.min(av.length, bv.length); + for (int i = 0; i < len; i++) { + int cmp = compareScVal(av[i], bv[i]); + if (cmp != 0) return cmp; + } + return Integer.compare(av.length, bv.length); + } + case SCV_MAP: + return compareMapEntries(a.getMap().getSCMap(), b.getMap().getSCMap()); + case SCV_ADDRESS: + return compareScAddress(a.getAddress(), b.getAddress()); + case SCV_ERROR: + { + int cmp = + Integer.compare( + a.getError().getDiscriminant().getValue(), + b.getError().getDiscriminant().getValue()); + if (cmp != 0) return cmp; + switch (a.getError().getDiscriminant()) { + case SCE_CONTRACT: + return Long.compare( + a.getError().getContractCode().getUint32().getNumber(), + b.getError().getContractCode().getUint32().getNumber()); + default: + return Integer.compare( + a.getError().getCode().getValue(), b.getError().getCode().getValue()); + } + } + case SCV_CONTRACT_INSTANCE: + { + int cmp = + compareContractExecutable( + a.getInstance().getExecutable(), b.getInstance().getExecutable()); + if (cmp != 0) return cmp; + return compareOptionalScMap(a.getInstance().getStorage(), b.getInstance().getStorage()); + } + case SCV_LEDGER_KEY_NONCE: + return Long.compare( + a.getNonce_key().getNonce().getInt64(), b.getNonce_key().getNonce().getInt64()); + default: + throw new IllegalArgumentException("Unsupported SCVal type: " + t); + } + } + + static int compareScAddress(SCAddress a, SCAddress b) { + int cmp = Integer.compare(a.getDiscriminant().getValue(), b.getDiscriminant().getValue()); + if (cmp != 0) return cmp; + + switch (a.getDiscriminant()) { + case SC_ADDRESS_TYPE_ACCOUNT: + return compareByteArrays( + a.getAccountId().getAccountID().getEd25519().getUint256(), + b.getAccountId().getAccountID().getEd25519().getUint256()); + case SC_ADDRESS_TYPE_CONTRACT: + return compareByteArrays( + a.getContractId().getContractID().getHash(), + b.getContractId().getContractID().getHash()); + case SC_ADDRESS_TYPE_MUXED_ACCOUNT: + { + cmp = + a.getMuxedAccount() + .getId() + .getUint64() + .getNumber() + .compareTo(b.getMuxedAccount().getId().getUint64().getNumber()); + if (cmp != 0) return cmp; + return compareByteArrays( + a.getMuxedAccount().getEd25519().getUint256(), + b.getMuxedAccount().getEd25519().getUint256()); + } + case SC_ADDRESS_TYPE_CLAIMABLE_BALANCE: + if (a.getClaimableBalanceId().getDiscriminant() + != org.stellar.sdk.xdr.ClaimableBalanceIDType.CLAIMABLE_BALANCE_ID_TYPE_V0) { + throw new IllegalArgumentException( + "Unsupported ClaimableBalanceID type: " + + a.getClaimableBalanceId().getDiscriminant()); + } + return compareByteArrays( + a.getClaimableBalanceId().getV0().getHash(), + b.getClaimableBalanceId().getV0().getHash()); + case SC_ADDRESS_TYPE_LIQUIDITY_POOL: + return compareByteArrays( + a.getLiquidityPoolId().getPoolID().getHash(), + b.getLiquidityPoolId().getPoolID().getHash()); + default: + throw new IllegalArgumentException("Unsupported SCAddress type: " + a.getDiscriminant()); + } + } + + static int compareContractExecutable(ContractExecutable a, ContractExecutable b) { + int cmp = Integer.compare(a.getDiscriminant().getValue(), b.getDiscriminant().getValue()); + if (cmp != 0) return cmp; + + switch (a.getDiscriminant()) { + case CONTRACT_EXECUTABLE_WASM: + return compareByteArrays(a.getWasm_hash().getHash(), b.getWasm_hash().getHash()); + case CONTRACT_EXECUTABLE_STELLAR_ASSET: + return 0; + default: + throw new IllegalArgumentException( + "Unsupported ContractExecutable type: " + a.getDiscriminant()); + } + } + + static int compareOptionalScMap(SCMap a, SCMap b) { + if (a == null && b == null) return 0; + if (a == null) return -1; + if (b == null) return 1; + return compareMapEntries(a.getSCMap(), b.getSCMap()); + } + + private static int compareMapEntries(SCMapEntry[] am, SCMapEntry[] bm) { + int len = Math.min(am.length, bm.length); + for (int i = 0; i < len; i++) { + int cmp = compareScVal(am[i].getKey(), bm[i].getKey()); + if (cmp != 0) return cmp; + cmp = compareScVal(am[i].getVal(), bm[i].getVal()); + if (cmp != 0) return cmp; + } + return Integer.compare(am.length, bm.length); + } + + /** Lexicographic unsigned byte comparison. */ + private static int compareByteArrays(byte[] a, byte[] b) { + int len = Math.min(a.length, b.length); + for (int i = 0; i < len; i++) { + int cmp = Integer.compare(a[i] & 0xFF, b[i] & 0xFF); + if (cmp != 0) return cmp; + } + return Integer.compare(a.length, b.length); + } +} diff --git a/src/main/java/org/stellar/sdk/scval/ScvMap.java b/src/main/java/org/stellar/sdk/scval/ScvMap.java index 60f0c6700..68132ce63 100644 --- a/src/main/java/org/stellar/sdk/scval/ScvMap.java +++ b/src/main/java/org/stellar/sdk/scval/ScvMap.java @@ -1,5 +1,6 @@ package org.stellar.sdk.scval; +import java.util.Arrays; import java.util.LinkedHashMap; import java.util.Map; import org.stellar.sdk.xdr.SCMap; @@ -11,14 +12,15 @@ class ScvMap { private static final SCValType TYPE = SCValType.SCV_MAP; - // we want to keep the order of the map entries - // this ensures that the generated XDR is deterministic. - static SCVal toSCVal(LinkedHashMap value) { + // Entries are sorted by key following Soroban runtime ordering rules, + // as the network requires ScMap keys to be in ascending order. + static SCVal toSCVal(Map value) { SCMapEntry[] scMapEntries = new SCMapEntry[value.size()]; int i = 0; for (Map.Entry entry : value.entrySet()) { scMapEntries[i++] = SCMapEntry.builder().key(entry.getKey()).val(entry.getValue()).build(); } + Arrays.sort(scMapEntries, (a, b) -> ScvComparator.compareScVal(a.getKey(), b.getKey())); return SCVal.builder().discriminant(TYPE).map(new SCMap(scMapEntries)).build(); } diff --git a/src/test/java/org/stellar/sdk/scval/ScvMapTest.java b/src/test/java/org/stellar/sdk/scval/ScvMapTest.java index 6d63de544..6bd97cac4 100644 --- a/src/test/java/org/stellar/sdk/scval/ScvMapTest.java +++ b/src/test/java/org/stellar/sdk/scval/ScvMapTest.java @@ -1,8 +1,12 @@ package org.stellar.sdk.scval; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.Map; import org.junit.Test; import org.stellar.sdk.xdr.SCMap; import org.stellar.sdk.xdr.SCMapEntry; @@ -22,16 +26,97 @@ public void testScvMap() { .map( new SCMap( new SCMapEntry[] { + SCMapEntry.builder().key(Scv.toString("key2")).val(Scv.toInt32(123)).build(), SCMapEntry.builder() .key(Scv.toSymbol("key1")) .val(Scv.toString("value1")) .build(), - SCMapEntry.builder().key(Scv.toString("key2")).val(Scv.toInt32(123)).build(), })) .build(); SCVal actualScVal = Scv.toMap(value); assertEquals(expectedScVal, actualScVal); - assertEquals(value, Scv.fromMap(actualScVal)); + } + + @Test + public void testToMapSortsKeys() { + Map value = new HashMap<>(); + value.put(Scv.toUint32(3), Scv.toVoid()); + value.put(Scv.toUint32(1), Scv.toVoid()); + value.put(Scv.toUint32(2), Scv.toVoid()); + + SCVal scVal = Scv.toMap(value); + assertNotNull(scVal.getMap()); + SCMapEntry[] entries = scVal.getMap().getSCMap(); + assertEquals(Scv.toUint32(1), entries[0].getKey()); + assertEquals(Scv.toUint32(2), entries[1].getKey()); + assertEquals(Scv.toUint32(3), entries[2].getKey()); + } + + @Test + public void testToMapStrictlyIncreasing() { + Map value = new HashMap<>(); + value.put(Scv.toSymbol("z"), Scv.toVoid()); + value.put(Scv.toSymbol("a"), Scv.toVoid()); + value.put(Scv.toUint32(100), Scv.toVoid()); + value.put(Scv.toBoolean(false), Scv.toVoid()); + value.put(Scv.toInt32(-1), Scv.toVoid()); + + SCVal scVal = Scv.toMap(value); + assertNotNull(scVal.getMap()); + SCMapEntry[] entries = scVal.getMap().getSCMap(); + for (int i = 0; i < entries.length - 1; i++) { + assertTrue( + "keys[" + i + "] not strictly less than keys[" + (i + 1) + "]", + ScvComparator.compareScVal(entries[i].getKey(), entries[i + 1].getKey()) < 0); + } + } + + @Test + public void testToMapMixedTypes() { + Map value = new HashMap<>(); + value.put(Scv.toSymbol("x"), Scv.toInt32(1)); + value.put(Scv.toUint32(42), Scv.toInt32(2)); + value.put(Scv.toBoolean(true), Scv.toInt32(3)); + + SCVal scVal = Scv.toMap(value); + assertNotNull(scVal.getMap()); + SCMapEntry[] entries = scVal.getMap().getSCMap(); + // SCV_BOOL(0) < SCV_U32(3) < SCV_SYMBOL(15) + assertEquals(Scv.toBoolean(true), entries[0].getKey()); + assertEquals(Scv.toUint32(42), entries[1].getKey()); + assertEquals(Scv.toSymbol("x"), entries[2].getKey()); + } + + @Test + public void testToMapSignedNegativeBoundaries() { + Map value = new HashMap<>(); + value.put(Scv.toInt32(0), Scv.toVoid()); + value.put(Scv.toInt32(Integer.MIN_VALUE), Scv.toVoid()); + value.put(Scv.toInt32(Integer.MAX_VALUE), Scv.toVoid()); + value.put(Scv.toInt32(-1), Scv.toVoid()); + + SCVal scVal = Scv.toMap(value); + assertNotNull(scVal.getMap()); + SCMapEntry[] entries = scVal.getMap().getSCMap(); + assertEquals(Scv.toInt32(Integer.MIN_VALUE), entries[0].getKey()); + assertEquals(Scv.toInt32(-1), entries[1].getKey()); + assertEquals(Scv.toInt32(0), entries[2].getKey()); + assertEquals(Scv.toInt32(Integer.MAX_VALUE), entries[3].getKey()); + } + + @Test + public void testToMapAlreadySortedIdempotent() { + Map value = new HashMap<>(); + value.put(Scv.toUint32(1), Scv.toInt32(10)); + value.put(Scv.toUint32(2), Scv.toInt32(20)); + value.put(Scv.toUint32(3), Scv.toInt32(30)); + + SCVal scVal = Scv.toMap(value); + assertNotNull(scVal.getMap()); + SCMapEntry[] entries = scVal.getMap().getSCMap(); + assertEquals(Scv.toUint32(1), entries[0].getKey()); + assertEquals(Scv.toUint32(2), entries[1].getKey()); + assertEquals(Scv.toUint32(3), entries[2].getKey()); } } diff --git a/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt b/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt new file mode 100644 index 000000000..125a000b1 --- /dev/null +++ b/src/test/kotlin/org/stellar/sdk/scval/ScvComparatorTest.kt @@ -0,0 +1,619 @@ +package org.stellar.sdk.scval + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.ints.shouldBeLessThan +import io.kotest.matchers.shouldBe +import java.math.BigInteger +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.ContractExecutableType +import org.stellar.sdk.xdr.ContractID +import org.stellar.sdk.xdr.Hash +import org.stellar.sdk.xdr.Int64 +import org.stellar.sdk.xdr.MuxedEd25519Account +import org.stellar.sdk.xdr.PoolID +import org.stellar.sdk.xdr.PublicKey +import org.stellar.sdk.xdr.PublicKeyType +import org.stellar.sdk.xdr.SCAddress +import org.stellar.sdk.xdr.SCAddressType +import org.stellar.sdk.xdr.SCContractInstance +import org.stellar.sdk.xdr.SCError +import org.stellar.sdk.xdr.SCErrorCode +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.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.XdrUnsignedHyperInteger +import org.stellar.sdk.xdr.XdrUnsignedInteger + +class ScvComparatorTest : + FunSpec({ + context("cross-type ordering") { + test("SCV_BOOL < SCV_VOID < SCV_U32 < SCV_SYMBOL") { + ScvComparator.compareScVal(Scv.toBoolean(true), Scv.toVoid()) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toVoid(), Scv.toUint32(0)) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toUint32(0), Scv.toSymbol("x")) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toSymbol("x"), Scv.toVoid()) shouldBeGreaterThan 0 + } + } + + context("SCV_BOOL") { + test("false < true") { + ScvComparator.compareScVal(Scv.toBoolean(false), Scv.toBoolean(true)) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toBoolean(true), Scv.toBoolean(false)) shouldBeGreaterThan 0 + ScvComparator.compareScVal(Scv.toBoolean(true), Scv.toBoolean(true)) shouldBe 0 + } + } + + context("SCV_VOID") { + test("void == void") { ScvComparator.compareScVal(Scv.toVoid(), Scv.toVoid()) shouldBe 0 } + } + + context("SCV_U32") { + test("numeric ordering") { + ScvComparator.compareScVal(Scv.toUint32(1), Scv.toUint32(2)) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toUint32(0), Scv.toUint32(0)) shouldBe 0 + } + } + + context("SCV_I32") { + test("signed ordering") { + ScvComparator.compareScVal(Scv.toInt32(-10), Scv.toInt32(-1)) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toInt32(-1), Scv.toInt32(0)) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toInt32(5), Scv.toInt32(5)) shouldBe 0 + } + } + + context("SCV_U64") { + test("unsigned ordering including max") { + val maxU64 = BigInteger("18446744073709551615") + ScvComparator.compareScVal( + Scv.toUint64(BigInteger.ZERO), + Scv.toUint64(maxU64), + ) shouldBeLessThan 0 + } + } + + context("SCV_I64") { + test("signed ordering") { + ScvComparator.compareScVal(Scv.toInt64(-1), Scv.toInt64(0)) shouldBeLessThan 0 + ScvComparator.compareScVal( + Scv.toInt64(Long.MIN_VALUE), + Scv.toInt64(Long.MAX_VALUE), + ) shouldBeLessThan 0 + } + } + + context("SCV_TIMEPOINT") { + test("numeric ordering") { + ScvComparator.compareScVal( + Scv.toTimePoint(BigInteger.ONE), + Scv.toTimePoint(BigInteger.valueOf(2)), + ) shouldBeLessThan 0 + } + } + + context("SCV_DURATION") { + test("numeric ordering") { + ScvComparator.compareScVal( + Scv.toDuration(BigInteger.ONE), + Scv.toDuration(BigInteger.valueOf(2)), + ) shouldBeLessThan 0 + } + } + + context("SCV_U128") { + test("tuple (hi, lo) ordering - hi differs") { + val twoTo64 = BigInteger.ONE.shiftLeft(64) + ScvComparator.compareScVal( + Scv.toUint128(twoTo64.subtract(BigInteger.ONE)), + Scv.toUint128(twoTo64), + ) shouldBeLessThan 0 + ScvComparator.compareScVal( + Scv.toUint128(BigInteger.ZERO), + Scv.toUint128(BigInteger.ZERO), + ) shouldBe 0 + } + + test("tuple (hi, lo) ordering - same hi, lo differs") { + val twoTo64 = BigInteger.ONE.shiftLeft(64) + // both have hi=1, but lo differs: 1*2^64+0 vs 1*2^64+1 + ScvComparator.compareScVal( + Scv.toUint128(twoTo64), + Scv.toUint128(twoTo64.add(BigInteger.ONE)), + ) shouldBeLessThan 0 + } + } + + context("SCV_I128") { + test("signed ordering including negative") { + val minI128 = BigInteger.ONE.shiftLeft(127).negate() + ScvComparator.compareScVal( + Scv.toInt128(minI128), + Scv.toInt128(BigInteger.ZERO), + ) shouldBeLessThan 0 + ScvComparator.compareScVal( + Scv.toInt128(BigInteger.valueOf(-1)), + Scv.toInt128(BigInteger.ZERO), + ) shouldBeLessThan 0 + } + + test("same hi, lo differs") { + // 1 and 2 both have hi=0, lo differs + ScvComparator.compareScVal( + Scv.toInt128(BigInteger.ONE), + Scv.toInt128(BigInteger.valueOf(2)), + ) shouldBeLessThan 0 + } + } + + context("SCV_U256") { + test("numeric ordering") { + ScvComparator.compareScVal( + Scv.toUint256(BigInteger.ZERO), + Scv.toUint256(BigInteger.ONE), + ) shouldBeLessThan 0 + } + + test("each component level determines ordering") { + val shift64 = BigInteger.ONE.shiftLeft(64) + val shift128 = BigInteger.ONE.shiftLeft(128) + val shift192 = BigInteger.ONE.shiftLeft(192) + // hi_hi differs + ScvComparator.compareScVal( + Scv.toUint256(shift192), + Scv.toUint256(shift192.add(shift192)), + ) shouldBeLessThan 0 + // same hi_hi, hi_lo differs + ScvComparator.compareScVal( + Scv.toUint256(shift128), + Scv.toUint256(shift128.add(shift128)), + ) shouldBeLessThan 0 + // same hi_hi+hi_lo, lo_hi differs + ScvComparator.compareScVal( + Scv.toUint256(shift64), + Scv.toUint256(shift64.add(shift64)), + ) shouldBeLessThan 0 + // same hi_hi+hi_lo+lo_hi, lo_lo differs + ScvComparator.compareScVal( + Scv.toUint256(BigInteger.ONE), + Scv.toUint256(BigInteger.valueOf(2)), + ) shouldBeLessThan 0 + } + } + + context("SCV_I256") { + test("signed ordering including negative") { + val minI256 = BigInteger.ONE.shiftLeft(255).negate() + ScvComparator.compareScVal( + Scv.toInt256(minI256), + Scv.toInt256(BigInteger.ZERO), + ) shouldBeLessThan 0 + ScvComparator.compareScVal( + Scv.toInt256(BigInteger.valueOf(-1)), + Scv.toInt256(BigInteger.ZERO), + ) shouldBeLessThan 0 + } + + test("each component level determines ordering") { + val shift64 = BigInteger.ONE.shiftLeft(64) + val shift128 = BigInteger.ONE.shiftLeft(128) + // same hi_hi(0), hi_lo differs + ScvComparator.compareScVal( + Scv.toInt256(shift128), + Scv.toInt256(shift128.add(shift128)), + ) shouldBeLessThan 0 + // same hi_hi+hi_lo(0), lo_hi differs + ScvComparator.compareScVal( + Scv.toInt256(shift64), + Scv.toInt256(shift64.add(shift64)), + ) shouldBeLessThan 0 + // same hi_hi+hi_lo+lo_hi(0), lo_lo differs + ScvComparator.compareScVal( + Scv.toInt256(BigInteger.ONE), + Scv.toInt256(BigInteger.valueOf(2)), + ) shouldBeLessThan 0 + } + } + + context("SCV_BYTES") { + test("lexicographic ordering and prefix rule") { + ScvComparator.compareScVal( + Scv.toBytes(byteArrayOf(0x61, 0x62)), + Scv.toBytes(byteArrayOf(0x61, 0x62, 0x63)), + ) shouldBeLessThan 0 + ScvComparator.compareScVal( + Scv.toBytes(byteArrayOf(0x61)), + Scv.toBytes(byteArrayOf(0x61)), + ) shouldBe 0 + } + + test("unsigned byte comparison: 0x80 > 0x7F") { + ScvComparator.compareScVal( + Scv.toBytes(byteArrayOf(0x7F)), + Scv.toBytes(byteArrayOf(0x80.toByte())), + ) shouldBeLessThan 0 + } + } + + context("SCV_STRING") { + test("lexicographic ordering and prefix rule") { + ScvComparator.compareScVal(Scv.toString("abc"), Scv.toString("abd")) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toString("ab"), Scv.toString("abc")) shouldBeLessThan 0 + } + } + + context("SCV_SYMBOL") { + test("lexicographic ordering") { + ScvComparator.compareScVal(Scv.toSymbol("alpha"), Scv.toSymbol("bravo")) shouldBeLessThan 0 + ScvComparator.compareScVal(Scv.toSymbol("x"), Scv.toSymbol("x")) shouldBe 0 + } + } + + context("SCV_VEC") { + test("element-by-element ordering") { + val a = listOf(Scv.toUint32(1), Scv.toUint32(2)) + val b = listOf(Scv.toUint32(1), Scv.toUint32(3)) + ScvComparator.compareScVal(Scv.toVec(a), Scv.toVec(b)) shouldBeLessThan 0 + } + + test("shorter < longer when prefix matches") { + ScvComparator.compareScVal( + Scv.toVec(listOf(Scv.toUint32(1))), + Scv.toVec(listOf(Scv.toUint32(1), Scv.toUint32(2))), + ) shouldBeLessThan 0 + } + + test("equal vecs") { + val v = listOf(Scv.toUint32(1), Scv.toUint32(2)) + ScvComparator.compareScVal(Scv.toVec(v), Scv.toVec(v)) shouldBe 0 + } + } + + context("SCV_MAP") { + test("compare by key") { + val a = makeMap(arrayOf(entry(Scv.toUint32(1), Scv.toVoid()))) + val b = makeMap(arrayOf(entry(Scv.toUint32(2), Scv.toVoid()))) + ScvComparator.compareScVal(a, b) shouldBeLessThan 0 + } + + test("compare by val when keys equal") { + val c = makeMap(arrayOf(entry(Scv.toUint32(1), Scv.toInt32(10)))) + val d = makeMap(arrayOf(entry(Scv.toUint32(1), Scv.toInt32(20)))) + ScvComparator.compareScVal(c, d) shouldBeLessThan 0 + } + + test("shorter < longer") { + val a = makeMap(arrayOf(entry(Scv.toUint32(1), Scv.toVoid()))) + val e = + makeMap( + arrayOf(entry(Scv.toUint32(1), Scv.toVoid()), entry(Scv.toUint32(2), Scv.toVoid())) + ) + ScvComparator.compareScVal(a, e) shouldBeLessThan 0 + } + + test("equal maps") { + val a = makeMap(arrayOf(entry(Scv.toUint32(1), Scv.toInt32(10)))) + val b = makeMap(arrayOf(entry(Scv.toUint32(1), Scv.toInt32(10)))) + ScvComparator.compareScVal(a, b) shouldBe 0 + } + } + + context("SCV_ERROR") { + test("SCE_CONTRACT < SCE_WASM_VM") { + val contractErr = makeErrorContract(1) + val wasmErr = makeErrorWasm(SCErrorCode.SCEC_ARITH_DOMAIN) + ScvComparator.compareScVal(contractErr, wasmErr) shouldBeLessThan 0 + } + + test("same type, different code") { + ScvComparator.compareScVal(makeErrorContract(1), makeErrorContract(2)) shouldBeLessThan 0 + ScvComparator.compareScVal(makeErrorContract(5), makeErrorContract(5)) shouldBe 0 + } + + test("non-contract error: compare by error code") { + val wasmErr1 = makeErrorWasm(SCErrorCode.SCEC_ARITH_DOMAIN) + val wasmErr2 = makeErrorWasm(SCErrorCode.SCEC_UNEXPECTED_SIZE) + ScvComparator.compareScVal(wasmErr1, wasmErr2) shouldBeLessThan 0 + ScvComparator.compareScVal(wasmErr1, wasmErr1) shouldBe 0 + } + } + + context("SCV_CONTRACT_INSTANCE") { + test("WASM < STELLAR_ASSET") { + val wasm = makeWasmInstance(bytes32(0x00), null) + val asset = makeStellarAssetInstance(null) + ScvComparator.compareScVal(wasm, asset) shouldBeLessThan 0 + } + + test("different wasm hash") { + ScvComparator.compareScVal( + makeWasmInstance(bytes32(0x00), null), + makeWasmInstance(bytes32Last(0x01), null), + ) shouldBeLessThan 0 + } + + test("null storage < non-null storage") { + val asset = makeStellarAssetInstance(null) + val storage = SCMap(arrayOf(entry(Scv.toUint32(1), Scv.toVoid()))) + ScvComparator.compareScVal(asset, makeStellarAssetInstance(storage)) shouldBeLessThan 0 + } + } + + context("SCV_LEDGER_KEY_NONCE") { + test("signed ordering") { + ScvComparator.compareScVal(makeNonce(-1), makeNonce(0)) shouldBeLessThan 0 + ScvComparator.compareScVal(makeNonce(42), makeNonce(42)) shouldBe 0 + } + } + + context("SCV_LEDGER_KEY_CONTRACT_INSTANCE") { + test("always equal") { + val a = SCVal.builder().discriminant(SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE).build() + val b = SCVal.builder().discriminant(SCValType.SCV_LEDGER_KEY_CONTRACT_INSTANCE).build() + ScvComparator.compareScVal(a, b) shouldBe 0 + } + } + + context("compareScAddress") { + test("address type ordering: account < contract < muxed < claimable < pool") { + val addrs = + arrayOf( + accountAddress(bytes32(0x00)), + contractAddress(bytes32(0x00)), + muxedAddress(0, bytes32(0x00)), + claimableBalanceAddress(bytes32(0x00)), + liquidityPoolAddress(bytes32(0x00)), + ) + for (i in 0 until addrs.size - 1) { + ScvComparator.compareScAddress(addrs[i], addrs[i + 1]) shouldBeLessThan 0 + } + } + + test("account: compare by ed25519") { + ScvComparator.compareScAddress( + accountAddress(bytes32(0x00)), + accountAddress(bytes32Last(0x01)), + ) shouldBeLessThan 0 + } + + test("contract: compare by hash") { + ScvComparator.compareScAddress( + contractAddress(bytes32(0x00)), + contractAddress(bytes32(0xFF)), + ) shouldBeLessThan 0 + } + + test("claimable balance: compare by hash") { + ScvComparator.compareScAddress( + claimableBalanceAddress(bytes32(0x00)), + claimableBalanceAddress(bytes32Last(0x01)), + ) shouldBeLessThan 0 + ScvComparator.compareScAddress( + claimableBalanceAddress(bytes32(0xAA)), + claimableBalanceAddress(bytes32(0xAA)), + ) shouldBe 0 + } + + test("liquidity pool: compare by hash") { + ScvComparator.compareScAddress( + liquidityPoolAddress(bytes32(0x00)), + liquidityPoolAddress(bytes32Last(0x01)), + ) shouldBeLessThan 0 + ScvComparator.compareScAddress( + liquidityPoolAddress(bytes32(0xBB)), + liquidityPoolAddress(bytes32(0xBB)), + ) shouldBe 0 + } + + test("muxed: id first, then ed25519") { + ScvComparator.compareScAddress( + muxedAddress(1, bytes32(0xFF)), + muxedAddress(2, bytes32(0x00)), + ) shouldBeLessThan 0 + ScvComparator.compareScAddress( + muxedAddress(1, bytes32(0x00)), + muxedAddress(1, bytes32Last(0x01)), + ) shouldBeLessThan 0 + ScvComparator.compareScAddress( + muxedAddress(5, bytes32(0xAB)), + muxedAddress(5, bytes32(0xAB)), + ) shouldBe 0 + } + } + + context("compareContractExecutable") { + test("WASM < STELLAR_ASSET") { + val wasm = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) + .wasm_hash(Hash(bytes32(0x00))) + .build() + val asset = + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) + .build() + ScvComparator.compareContractExecutable(wasm, asset) shouldBeLessThan 0 + ScvComparator.compareContractExecutable(asset, asset) shouldBe 0 + } + } + + context("compareOptionalScMap") { + test("null handling and ordering") { + val m1 = SCMap(arrayOf(entry(Scv.toUint32(1), Scv.toVoid()))) + val m2 = SCMap(arrayOf(entry(Scv.toUint32(2), Scv.toVoid()))) + + ScvComparator.compareOptionalScMap(null, null) shouldBe 0 + ScvComparator.compareOptionalScMap(null, m1) shouldBeLessThan 0 + ScvComparator.compareOptionalScMap(m1, null) shouldBeGreaterThan 0 + ScvComparator.compareOptionalScMap(m1, m2) shouldBeLessThan 0 + ScvComparator.compareOptionalScMap(m1, m1) shouldBe 0 + } + } + + context("Comparator interface") { + test("INSTANCE.compare delegates to compareScVal") { + ScvComparator.INSTANCE.compare(Scv.toUint32(1), Scv.toUint32(2)) shouldBeLessThan 0 + ScvComparator.INSTANCE.compare(Scv.toUint32(2), Scv.toUint32(1)) shouldBeGreaterThan 0 + ScvComparator.INSTANCE.compare(Scv.toUint32(1), Scv.toUint32(1)) shouldBe 0 + } + } + + context("antisymmetry") { + test("compare(a,b) == -compare(b,a)") { + val pairs = + arrayOf( + arrayOf(Scv.toBoolean(false), Scv.toBoolean(true)), + arrayOf(Scv.toUint32(1), Scv.toUint32(2)), + arrayOf(Scv.toInt32(-10), Scv.toInt32(10)), + arrayOf(Scv.toSymbol("a"), Scv.toSymbol("b")), + arrayOf(Scv.toBoolean(false), Scv.toUint32(0)), + arrayOf(Scv.toInt32(0), Scv.toSymbol("x")), + ) + for ((i, pair) in pairs.withIndex()) { + ScvComparator.compareScVal(pair[0], pair[1]) shouldBe + -ScvComparator.compareScVal(pair[1], pair[0]) + } + } + } + + context("transitivity") { + test("a < b < c implies a < c") { + val a = Scv.toBoolean(true) + val b = Scv.toUint32(0) + val c = Scv.toSymbol("x") + ScvComparator.compareScVal(a, b) shouldBeLessThan 0 + ScvComparator.compareScVal(b, c) shouldBeLessThan 0 + ScvComparator.compareScVal(a, c) shouldBeLessThan 0 + } + } + }) { + companion object { + private fun bytes32(fill: Int): ByteArray { + val b = ByteArray(32) + b.fill(fill.toByte()) + return b + } + + private fun bytes32Last(last: Int): ByteArray { + val b = ByteArray(32) + b[31] = last.toByte() + return b + } + + private fun entry(key: SCVal, value: SCVal): SCMapEntry = + SCMapEntry.builder().key(key).`val`(value).build() + + private fun makeMap(entries: Array): SCVal = + SCVal.builder().discriminant(SCValType.SCV_MAP).map(SCMap(entries)).build() + + private fun makeErrorContract(code: Int): SCVal = + SCVal.builder() + .discriminant(SCValType.SCV_ERROR) + .error( + SCError.builder() + .discriminant(SCErrorType.SCE_CONTRACT) + .contractCode(Uint32(XdrUnsignedInteger(code.toLong()))) + .build() + ) + .build() + + private fun makeErrorWasm(code: SCErrorCode): SCVal = + SCVal.builder() + .discriminant(SCValType.SCV_ERROR) + .error(SCError.builder().discriminant(SCErrorType.SCE_WASM_VM).code(code).build()) + .build() + + private fun makeWasmInstance(wasmHash: ByteArray, storage: SCMap?): SCVal = + SCVal.builder() + .discriminant(SCValType.SCV_CONTRACT_INSTANCE) + .instance( + SCContractInstance.builder() + .executable( + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_WASM) + .wasm_hash(Hash(wasmHash)) + .build() + ) + .storage(storage) + .build() + ) + .build() + + private fun makeStellarAssetInstance(storage: SCMap?): SCVal = + SCVal.builder() + .discriminant(SCValType.SCV_CONTRACT_INSTANCE) + .instance( + SCContractInstance.builder() + .executable( + ContractExecutable.builder() + .discriminant(ContractExecutableType.CONTRACT_EXECUTABLE_STELLAR_ASSET) + .build() + ) + .storage(storage) + .build() + ) + .build() + + private fun makeNonce(v: Long): SCVal = + SCVal.builder() + .discriminant(SCValType.SCV_LEDGER_KEY_NONCE) + .nonce_key(SCNonceKey.builder().nonce(Int64(v)).build()) + .build() + + private fun accountAddress(key: ByteArray): SCAddress = + SCAddress.builder() + .discriminant(SCAddressType.SC_ADDRESS_TYPE_ACCOUNT) + .accountId( + AccountID( + PublicKey.builder() + .discriminant(PublicKeyType.PUBLIC_KEY_TYPE_ED25519) + .ed25519(Uint256(key)) + .build() + ) + ) + .build() + + private fun contractAddress(hash: ByteArray): SCAddress = + SCAddress.builder() + .discriminant(SCAddressType.SC_ADDRESS_TYPE_CONTRACT) + .contractId(ContractID(Hash(hash))) + .build() + + private fun muxedAddress(id: Long, key: ByteArray): SCAddress = + SCAddress.builder() + .discriminant(SCAddressType.SC_ADDRESS_TYPE_MUXED_ACCOUNT) + .muxedAccount( + MuxedEd25519Account.builder() + .id(Uint64(XdrUnsignedHyperInteger(BigInteger.valueOf(id)))) + .ed25519(Uint256(key)) + .build() + ) + .build() + + private fun claimableBalanceAddress(hash: ByteArray): SCAddress = + SCAddress.builder() + .discriminant(SCAddressType.SC_ADDRESS_TYPE_CLAIMABLE_BALANCE) + .claimableBalanceId( + ClaimableBalanceID.builder() + .discriminant(ClaimableBalanceIDType.CLAIMABLE_BALANCE_ID_TYPE_V0) + .v0(Hash(hash)) + .build() + ) + .build() + + private fun liquidityPoolAddress(hash: ByteArray): SCAddress = + SCAddress.builder() + .discriminant(SCAddressType.SC_ADDRESS_TYPE_LIQUIDITY_POOL) + .liquidityPoolId(PoolID(Hash(hash))) + .build() + } +}