Skip to content

Commit 3610dc6

Browse files
authored
feat: sort ScMap entries by key in Scv.toMap following Soroban runtime ordering (#766)
1 parent 64abf03 commit 3610dc6

6 files changed

Lines changed: 1036 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Changelog
22

33
## Pending
4+
- 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<SCVal, SCVal>`; the previous `toMap(LinkedHashMap<SCVal, SCVal>)` overload is deprecated.
45

56
## 2.2.3
67

src/main/java/org/stellar/sdk/scval/Scv.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,27 @@ public static long fromLedgerKeyNonce(SCVal scVal) {
269269
/**
270270
* Build a {@link SCVal} with the type of {@link SCValType#SCV_MAP}.
271271
*
272+
* <p>The entries are sorted by key following Soroban runtime ordering rules, as the network
273+
* requires ScMap keys to be in ascending order.
274+
*
275+
* @param map map to convert
276+
* @return {@link SCVal} with the type of {@link SCValType#SCV_MAP}
277+
*/
278+
public static SCVal toMap(Map<SCVal, SCVal> map) {
279+
return ScvMap.toSCVal(map);
280+
}
281+
282+
/**
283+
* Build a {@link SCVal} with the type of {@link SCValType#SCV_MAP}.
284+
*
285+
* <p>The entries are sorted by key following Soroban runtime ordering rules, as the network
286+
* requires ScMap keys to be in ascending order.
287+
*
272288
* @param map map to convert
273289
* @return {@link SCVal} with the type of {@link SCValType#SCV_MAP}
290+
* @deprecated Use {@link #toMap(Map)} instead.
274291
*/
292+
@Deprecated
275293
public static SCVal toMap(LinkedHashMap<SCVal, SCVal> map) {
276294
return ScvMap.toSCVal(map);
277295
}
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
package org.stellar.sdk.scval;
2+
3+
import java.util.Comparator;
4+
import org.stellar.sdk.xdr.ContractExecutable;
5+
import org.stellar.sdk.xdr.SCAddress;
6+
import org.stellar.sdk.xdr.SCMap;
7+
import org.stellar.sdk.xdr.SCMapEntry;
8+
import org.stellar.sdk.xdr.SCVal;
9+
import org.stellar.sdk.xdr.SCValType;
10+
11+
/**
12+
* Comparator for {@link SCVal} values following Soroban runtime ordering rules.
13+
*
14+
* <p>This mirrors Rust's {@code #[derive(Ord)]} on the {@code ScVal} enum in {@code
15+
* rs-soroban-env}.
16+
*
17+
* <p>Comparison rules:
18+
*
19+
* <ol>
20+
* <li><b>Cross-type</b>: compare by {@link SCValType} discriminant value ({@code SCV_BOOL=0 <
21+
* SCV_VOID=1 < ... < SCV_LEDGER_KEY_NONCE=21}).
22+
* <li><b>Same-type</b> (by variant):
23+
* <ul>
24+
* <li>{@code SCV_BOOL}: {@code False (0) < True (1)}
25+
* <li>{@code SCV_VOID}, {@code SCV_LEDGER_KEY_CONTRACT_INSTANCE}: always equal
26+
* <li>{@code SCV_U32 / I32 / U64 / I64}: numeric comparison
27+
* <li>{@code SCV_TIMEPOINT / DURATION}: numeric comparison of the underlying uint64
28+
* <li>{@code SCV_U128}: tuple comparison {@code (hi, lo)} (both unsigned)
29+
* <li>{@code SCV_I128}: tuple comparison {@code (hi, lo)} (hi signed, lo unsigned)
30+
* <li>{@code SCV_U256}: tuple comparison {@code (hi_hi, hi_lo, lo_hi, lo_lo)} (all
31+
* unsigned)
32+
* <li>{@code SCV_I256}: tuple comparison {@code (hi_hi, hi_lo, lo_hi, lo_lo)} (hi_hi
33+
* signed)
34+
* <li>{@code SCV_BYTES / STRING / SYMBOL}: lexicographic byte comparison
35+
* <li>{@code SCV_VEC}: element-by-element, shorter &lt; longer
36+
* <li>{@code SCV_MAP}: entry-by-entry (key first, then val), shorter &lt; longer
37+
* <li>{@code SCV_ADDRESS}: by address type discriminant, then structurally per variant
38+
* <li>{@code SCV_ERROR}: by error type discriminant, then contract_code or error code
39+
* <li>{@code SCV_CONTRACT_INSTANCE}: by executable type, then wasm_hash, then storage
40+
* <li>{@code SCV_LEDGER_KEY_NONCE}: signed numeric comparison of nonce
41+
* </ul>
42+
* </ol>
43+
*/
44+
class ScvComparator implements Comparator<SCVal> {
45+
static final ScvComparator INSTANCE = new ScvComparator();
46+
47+
@Override
48+
public int compare(SCVal a, SCVal b) {
49+
return compareScVal(a, b);
50+
}
51+
52+
static int compareScVal(SCVal a, SCVal b) {
53+
if (a.getDiscriminant() != b.getDiscriminant()) {
54+
return Integer.compare(a.getDiscriminant().getValue(), b.getDiscriminant().getValue());
55+
}
56+
57+
SCValType t = a.getDiscriminant();
58+
switch (t) {
59+
case SCV_BOOL:
60+
return Boolean.compare(a.getB(), b.getB());
61+
case SCV_VOID:
62+
case SCV_LEDGER_KEY_CONTRACT_INSTANCE:
63+
return 0;
64+
case SCV_U32:
65+
return Long.compare(a.getU32().getUint32().getNumber(), b.getU32().getUint32().getNumber());
66+
case SCV_I32:
67+
return Integer.compare(a.getI32().getInt32(), b.getI32().getInt32());
68+
case SCV_U64:
69+
return a.getU64().getUint64().getNumber().compareTo(b.getU64().getUint64().getNumber());
70+
case SCV_I64:
71+
return Long.compare(a.getI64().getInt64(), b.getI64().getInt64());
72+
case SCV_TIMEPOINT:
73+
return a.getTimepoint()
74+
.getTimePoint()
75+
.getUint64()
76+
.getNumber()
77+
.compareTo(b.getTimepoint().getTimePoint().getUint64().getNumber());
78+
case SCV_DURATION:
79+
return a.getDuration()
80+
.getDuration()
81+
.getUint64()
82+
.getNumber()
83+
.compareTo(b.getDuration().getDuration().getUint64().getNumber());
84+
case SCV_U128:
85+
{
86+
int cmp =
87+
a.getU128()
88+
.getHi()
89+
.getUint64()
90+
.getNumber()
91+
.compareTo(b.getU128().getHi().getUint64().getNumber());
92+
if (cmp != 0) return cmp;
93+
return a.getU128()
94+
.getLo()
95+
.getUint64()
96+
.getNumber()
97+
.compareTo(b.getU128().getLo().getUint64().getNumber());
98+
}
99+
case SCV_I128:
100+
{
101+
int cmp = Long.compare(a.getI128().getHi().getInt64(), b.getI128().getHi().getInt64());
102+
if (cmp != 0) return cmp;
103+
return a.getI128()
104+
.getLo()
105+
.getUint64()
106+
.getNumber()
107+
.compareTo(b.getI128().getLo().getUint64().getNumber());
108+
}
109+
case SCV_U256:
110+
{
111+
int cmp =
112+
a.getU256()
113+
.getHi_hi()
114+
.getUint64()
115+
.getNumber()
116+
.compareTo(b.getU256().getHi_hi().getUint64().getNumber());
117+
if (cmp != 0) return cmp;
118+
cmp =
119+
a.getU256()
120+
.getHi_lo()
121+
.getUint64()
122+
.getNumber()
123+
.compareTo(b.getU256().getHi_lo().getUint64().getNumber());
124+
if (cmp != 0) return cmp;
125+
cmp =
126+
a.getU256()
127+
.getLo_hi()
128+
.getUint64()
129+
.getNumber()
130+
.compareTo(b.getU256().getLo_hi().getUint64().getNumber());
131+
if (cmp != 0) return cmp;
132+
return a.getU256()
133+
.getLo_lo()
134+
.getUint64()
135+
.getNumber()
136+
.compareTo(b.getU256().getLo_lo().getUint64().getNumber());
137+
}
138+
case SCV_I256:
139+
{
140+
int cmp =
141+
Long.compare(a.getI256().getHi_hi().getInt64(), b.getI256().getHi_hi().getInt64());
142+
if (cmp != 0) return cmp;
143+
cmp =
144+
a.getI256()
145+
.getHi_lo()
146+
.getUint64()
147+
.getNumber()
148+
.compareTo(b.getI256().getHi_lo().getUint64().getNumber());
149+
if (cmp != 0) return cmp;
150+
cmp =
151+
a.getI256()
152+
.getLo_hi()
153+
.getUint64()
154+
.getNumber()
155+
.compareTo(b.getI256().getLo_hi().getUint64().getNumber());
156+
if (cmp != 0) return cmp;
157+
return a.getI256()
158+
.getLo_lo()
159+
.getUint64()
160+
.getNumber()
161+
.compareTo(b.getI256().getLo_lo().getUint64().getNumber());
162+
}
163+
case SCV_BYTES:
164+
return compareByteArrays(a.getBytes().getSCBytes(), b.getBytes().getSCBytes());
165+
case SCV_STRING:
166+
return compareByteArrays(
167+
a.getStr().getSCString().getBytes(), b.getStr().getSCString().getBytes());
168+
case SCV_SYMBOL:
169+
return compareByteArrays(
170+
a.getSym().getSCSymbol().getBytes(), b.getSym().getSCSymbol().getBytes());
171+
case SCV_VEC:
172+
{
173+
SCVal[] av = a.getVec().getSCVec();
174+
SCVal[] bv = b.getVec().getSCVec();
175+
int len = Math.min(av.length, bv.length);
176+
for (int i = 0; i < len; i++) {
177+
int cmp = compareScVal(av[i], bv[i]);
178+
if (cmp != 0) return cmp;
179+
}
180+
return Integer.compare(av.length, bv.length);
181+
}
182+
case SCV_MAP:
183+
return compareMapEntries(a.getMap().getSCMap(), b.getMap().getSCMap());
184+
case SCV_ADDRESS:
185+
return compareScAddress(a.getAddress(), b.getAddress());
186+
case SCV_ERROR:
187+
{
188+
int cmp =
189+
Integer.compare(
190+
a.getError().getDiscriminant().getValue(),
191+
b.getError().getDiscriminant().getValue());
192+
if (cmp != 0) return cmp;
193+
switch (a.getError().getDiscriminant()) {
194+
case SCE_CONTRACT:
195+
return Long.compare(
196+
a.getError().getContractCode().getUint32().getNumber(),
197+
b.getError().getContractCode().getUint32().getNumber());
198+
default:
199+
return Integer.compare(
200+
a.getError().getCode().getValue(), b.getError().getCode().getValue());
201+
}
202+
}
203+
case SCV_CONTRACT_INSTANCE:
204+
{
205+
int cmp =
206+
compareContractExecutable(
207+
a.getInstance().getExecutable(), b.getInstance().getExecutable());
208+
if (cmp != 0) return cmp;
209+
return compareOptionalScMap(a.getInstance().getStorage(), b.getInstance().getStorage());
210+
}
211+
case SCV_LEDGER_KEY_NONCE:
212+
return Long.compare(
213+
a.getNonce_key().getNonce().getInt64(), b.getNonce_key().getNonce().getInt64());
214+
default:
215+
throw new IllegalArgumentException("Unsupported SCVal type: " + t);
216+
}
217+
}
218+
219+
static int compareScAddress(SCAddress a, SCAddress b) {
220+
int cmp = Integer.compare(a.getDiscriminant().getValue(), b.getDiscriminant().getValue());
221+
if (cmp != 0) return cmp;
222+
223+
switch (a.getDiscriminant()) {
224+
case SC_ADDRESS_TYPE_ACCOUNT:
225+
return compareByteArrays(
226+
a.getAccountId().getAccountID().getEd25519().getUint256(),
227+
b.getAccountId().getAccountID().getEd25519().getUint256());
228+
case SC_ADDRESS_TYPE_CONTRACT:
229+
return compareByteArrays(
230+
a.getContractId().getContractID().getHash(),
231+
b.getContractId().getContractID().getHash());
232+
case SC_ADDRESS_TYPE_MUXED_ACCOUNT:
233+
{
234+
cmp =
235+
a.getMuxedAccount()
236+
.getId()
237+
.getUint64()
238+
.getNumber()
239+
.compareTo(b.getMuxedAccount().getId().getUint64().getNumber());
240+
if (cmp != 0) return cmp;
241+
return compareByteArrays(
242+
a.getMuxedAccount().getEd25519().getUint256(),
243+
b.getMuxedAccount().getEd25519().getUint256());
244+
}
245+
case SC_ADDRESS_TYPE_CLAIMABLE_BALANCE:
246+
if (a.getClaimableBalanceId().getDiscriminant()
247+
!= org.stellar.sdk.xdr.ClaimableBalanceIDType.CLAIMABLE_BALANCE_ID_TYPE_V0) {
248+
throw new IllegalArgumentException(
249+
"Unsupported ClaimableBalanceID type: "
250+
+ a.getClaimableBalanceId().getDiscriminant());
251+
}
252+
return compareByteArrays(
253+
a.getClaimableBalanceId().getV0().getHash(),
254+
b.getClaimableBalanceId().getV0().getHash());
255+
case SC_ADDRESS_TYPE_LIQUIDITY_POOL:
256+
return compareByteArrays(
257+
a.getLiquidityPoolId().getPoolID().getHash(),
258+
b.getLiquidityPoolId().getPoolID().getHash());
259+
default:
260+
throw new IllegalArgumentException("Unsupported SCAddress type: " + a.getDiscriminant());
261+
}
262+
}
263+
264+
static int compareContractExecutable(ContractExecutable a, ContractExecutable b) {
265+
int cmp = Integer.compare(a.getDiscriminant().getValue(), b.getDiscriminant().getValue());
266+
if (cmp != 0) return cmp;
267+
268+
switch (a.getDiscriminant()) {
269+
case CONTRACT_EXECUTABLE_WASM:
270+
return compareByteArrays(a.getWasm_hash().getHash(), b.getWasm_hash().getHash());
271+
case CONTRACT_EXECUTABLE_STELLAR_ASSET:
272+
return 0;
273+
default:
274+
throw new IllegalArgumentException(
275+
"Unsupported ContractExecutable type: " + a.getDiscriminant());
276+
}
277+
}
278+
279+
static int compareOptionalScMap(SCMap a, SCMap b) {
280+
if (a == null && b == null) return 0;
281+
if (a == null) return -1;
282+
if (b == null) return 1;
283+
return compareMapEntries(a.getSCMap(), b.getSCMap());
284+
}
285+
286+
private static int compareMapEntries(SCMapEntry[] am, SCMapEntry[] bm) {
287+
int len = Math.min(am.length, bm.length);
288+
for (int i = 0; i < len; i++) {
289+
int cmp = compareScVal(am[i].getKey(), bm[i].getKey());
290+
if (cmp != 0) return cmp;
291+
cmp = compareScVal(am[i].getVal(), bm[i].getVal());
292+
if (cmp != 0) return cmp;
293+
}
294+
return Integer.compare(am.length, bm.length);
295+
}
296+
297+
/** Lexicographic unsigned byte comparison. */
298+
private static int compareByteArrays(byte[] a, byte[] b) {
299+
int len = Math.min(a.length, b.length);
300+
for (int i = 0; i < len; i++) {
301+
int cmp = Integer.compare(a[i] & 0xFF, b[i] & 0xFF);
302+
if (cmp != 0) return cmp;
303+
}
304+
return Integer.compare(a.length, b.length);
305+
}
306+
}

src/main/java/org/stellar/sdk/scval/ScvMap.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.stellar.sdk.scval;
22

3+
import java.util.Arrays;
34
import java.util.LinkedHashMap;
45
import java.util.Map;
56
import org.stellar.sdk.xdr.SCMap;
@@ -11,14 +12,15 @@
1112
class ScvMap {
1213
private static final SCValType TYPE = SCValType.SCV_MAP;
1314

14-
// we want to keep the order of the map entries
15-
// this ensures that the generated XDR is deterministic.
16-
static SCVal toSCVal(LinkedHashMap<SCVal, SCVal> value) {
15+
// Entries are sorted by key following Soroban runtime ordering rules,
16+
// as the network requires ScMap keys to be in ascending order.
17+
static SCVal toSCVal(Map<SCVal, SCVal> value) {
1718
SCMapEntry[] scMapEntries = new SCMapEntry[value.size()];
1819
int i = 0;
1920
for (Map.Entry<SCVal, SCVal> entry : value.entrySet()) {
2021
scMapEntries[i++] = SCMapEntry.builder().key(entry.getKey()).val(entry.getValue()).build();
2122
}
23+
Arrays.sort(scMapEntries, (a, b) -> ScvComparator.compareScVal(a.getKey(), b.getKey()));
2224
return SCVal.builder().discriminant(TYPE).map(new SCMap(scMapEntries)).build();
2325
}
2426

0 commit comments

Comments
 (0)