This release replaces the method-call-style XDR API (backed by @stellar/js-xdr
v4) with a class-based one built on @stellar/js-xdr v5. The wire format is
unchanged, but every XDR value now exposes a different API:
discriminated-union classes with property access instead of method-call-style
getters and setters.
This guide documents every user-visible change so you can update existing code.
The same release also switches the SDK’s public byte-returning APIs from
Buffer to Uint8Array. That change reaches beyond the XDR layer and has its
own guide: the Uint8Array migration guide.
Read both, because Buffer methods like .toString("hex") and
.toString("utf8") fail silently on a Uint8Array, which is the most common
way this migration goes wrong.
1. Method-name changes (XDR/JSON acronyms → PascalCase)
The all-caps acronyms in method names normalize to single-initial-cap form. This affects application code that called the renamed methods directly.
True renames, where the legacy method existed with the all-caps name. Every
one of these keeps working as a deprecated alias of the new name, so existing
call sites compile and run — but on xdr.* values the aliases have the new
semantics (§ 7): toXDR() returns a Uint8Array, not a Buffer, so
.toXDR().toString("base64") still breaks. The wrapper-class aliases behave as
they did in v16 (Transaction.toXDR() returns a base64 string). Rename the
calls when you touch them:
| Before | After |
|---|---|
value.toXDR() | value.toXdr() |
Class.fromXDR(…) | Class.fromXdr(…) |
Class.validateXDR(…) | Class.validateXdr(…) |
value.toXDRObject() | value.toXdrObject() |
Class.fromXDRObject(…) | Class.fromXdrObject(…) |
asset.toChangeTrustXDRObject() | asset.toChangeTrustXdrObject() |
asset.toTrustLineXDRObject() | asset.toTrustLineXdrObject() |
This reaches well beyond the xdr namespace: the wrapper classes you use every
day carry these methods too, including some of the SDK’s most-called APIs. Each
keeps its all-caps spelling as a deprecated alias. The full list of renamed
public methods:
| Class | Renamed |
|---|---|
Transaction, FeeBumpTransaction | toXDR() → toXdr() |
TransactionBuilder | fromXDR() → fromXdr() |
contract.AssembledTransaction | toXDR() / fromXDR() → toXdr() / fromXdr() |
SorobanDataBuilder | fromXDR() → fromXdr() |
Asset | toXDRObject() → toXdrObject() |
Memo | toXDRObject() / fromXDRObject() → toXdrObject() / fromXdrObject() |
Operation | fromXDRObject() → fromXdrObject() |
Claimant | toXDRObject() → toXdrObject(), fromXDR() → fromXdr() |
MuxedAccount | toXDRObject() → toXdrObject() |
LiquidityPoolAsset, LiquidityPoolId | toXDRObject() → toXdrObject() |
contract.Client.txFromJSON was likewise renamed to txFromJson, but it keeps
a deprecated alias, so existing calls still work.
Net-new methods, with no legacy equivalent:
| Method | Description |
|---|---|
value.toXdrObject() on XDR values | Bridges instance ↔ wire-shape object. Legacy XDR types held their wire shape directly, so the distinction wasn’t meaningful. (On the wrapper classes above it’s a rename, not a new method.) |
value.toJson() / Class.fromJson(json) | SEP-51-compliant JSON serialization. See § 12. |
value.equals(other) | Structural comparison of two XDR values — use it for byte wrappers, where Array.from() returns [] and makes an assertion pass vacuously. Raw byte fields have no .equals() (§ 6). |
bytes.toBytes() on byte wrappers | Unwraps a Hash, Signature, ScBytes, … to the Uint8Array it holds; same as reading .value (§ 6). |
Note that toJson() (lowercase) is the API to call; the JavaScript-standard
toJSON() hook is also implemented as a thin delegate to it, so
JSON.stringify() produces SEP-0051 output for any XDR value (including ones
nested inside plain objects). See § 12.
Struct field names (accountId, sponsoredId, offerId, balanceId,
sellerId, …) are unchanged. Most type names are too, because the
PascalCase-with-collapsed-acronyms rule (AccountId, ScVal, TtlEntry,
HashIdPreimage, ScSpecUdtUnionV0, …) already matched the legacy SDK’s public
surface. But a handful of type names did change; see § 1.1.
1.1 Renamed and removed type names
Three renames. The first two are easy to miss because only the unsigned spelling changed:
| Legacy | Now |
|---|---|
UInt128Parts | Uint128Parts |
UInt256Parts | Uint256Parts |
ThresholdIndices | ThresholdIndexes |
And the following typedef aliases are gone entirely. The new layer inlines what they stood for rather than exporting a name for it:
| Legacy alias | Was | Now write |
|---|---|---|
Duration, TimePoint | Uint64 | bigint |
SequenceNumber | Int64 | bigint |
ScVec | array of ScVal | xdr.ScVal[] |
ScMap | array of ScMapEntry | xdr.ScMapEntry[] |
LedgerEntryChanges | array of LedgerEntryChange | xdr.LedgerEntryChange[] |
ContractCostParams | array of ContractCostParamEntry | xdr.ContractCostParamEntry[] |
SorobanAuthorizationEntries | array of SorobanAuthorizationEntry | xdr.SorobanAuthorizationEntry[] |
ScString, ScSymbol, String32, String64 | XDRString | xdr.XdrString (§ 11) |
SponsorshipDescriptor | undefined | AccountId | xdr.AccountId | null (§ 14) |
These only bite type annotations and import type lines. The runtime values
were always the underlying types.
The one runtime use the array typedefs had was encoding or decoding a whole
list as a single length-prefixed blob (e.g.
xdr.LedgerEntryChanges.fromXDR(feeMetaXdr, "base64") on Horizon’s
fee_meta_xdr). That job moved to the generic encodeArray / decodeArray
helpers, which work with any generated XDR class:
const changes = xdr.decodeArray(xdr.LedgerEntryChange, feeMetaXdr, "base64");const blob = xdr.encodeArray(xdr.LedgerEntryChange, changes, "base64");Both throw a TypeError naming the argument if it has no static schema: the
integer shims (§ 4) and the abstract base classes are not usable here.
This is only for the bare typedef wire format. Lists exchanged as one base64
string per element (RPC simulation auth entries, operation.auth) still use
value.toXdr("base64") / Type.fromXdr(s, "base64") per element.
2. Union types: discriminated classes, not switch/value pairs
The biggest behavioral change. Every XDR union (and any type defined like one:
Asset, ScVal, OperationBody, LedgerEntryData, TransactionEnvelope,
all the *Result types, etc.) is now a TypeScript discriminated union of
concrete variant classes.
.switch() → .type (string literal)
// Beforeif (op.body().switch() === xdr.OperationType.payment()) { … }
// Afterif (op.body.type === "payment") { … }obj.type is a literal-typed string. TypeScript narrows on it, so you don’t
need as casts inside switch (obj.type).
.value() and arm-getters → properties
// Beforeconst scv = xdr.ScVal.scvU32(42);scv.value(); // 42scv.switch(); // xdr.ScValType.scvU32()
const asset = ...; // xdr.Assetasset.alphaNum4(); // returns the AlphaNum4 payload
// Afterconst scv = xdr.ScVal.scvU32(42);scv.value; // 42 (property)scv.type; // "scvU32" (literal string)scv.u32; // 42 (variant-specific named field also works)
// Asset, after narrowing:if (asset.type === "assetTypeCreditAlphanum4") { asset.alphaNum4; // property on the AssetCreditAlphanum4 variant class asset.value; // same thing — every variant exposes `.value`}Construction: factories instead of new XdrType(disc, value)
The legacy new xdr.UnionType(discriminant, payload) pattern is gone (the base
class is now abstract). Use the per-variant factory:
// Beforenew xdr.AccountEntryExt(0);new xdr.LedgerEntryExt(0);new xdr.SorobanTransactionDataExt(0);new xdr.ContractEventBody(0, new xdr.ContractEventV0({ … }));new xdr.TransactionMeta(2, transactionMetaV2);new xdr.ExtensionPoint(0);
// Afterxdr.AccountEntryExt.v0();xdr.LedgerEntryExt.v0();xdr.SorobanTransactionDataExt.v0();xdr.ContractEventBody.v0(new xdr.ContractEventV0({ … }));xdr.TransactionMeta.v2(transactionMetaV2);xdr.ExtensionPoint.v0();The legacy form throws a TypeError naming a factory to use instead, so a call
site TypeScript can’t reach — plain JavaScript, or TypeScript run without a
type-check pass — fails at the new rather than later, inside serialization:
TypeError: new xdr.TransactionMeta(...) is not supported: XDR unions are builtfrom per-variant factories. Call xdr.TransactionMeta.operations(...) (or anotherarm factory) instead.Narrowing helpers
Two helpers ship on the xdr namespace for narrowing a union to a specific
variant when a full switch/if on .type is overkill:
import { xdr } from "@stellar/stellar-sdk";
// Assert-and-narrow — throws TypeError on mismatchconst v1 = xdr.expectUnionVariant(tx.toEnvelope(), "envelopeTypeTx").v1;const cond = xdr.expectUnionVariant(v1.tx.cond, "precondV2").v2;// cond is fully typed PreconditionsV2 here
// Type-guard form — narrows inside the branchif (xdr.isUnionVariant(scv, "scvU32")) { scv.u32; // number}3. Enums: singletons, not factory calls
// Before — factory-call returning an enum singletonxdr.AssetType.assetTypeNative();xdr.ScValType.scvU32();xdr.SignerKeyType.signerKeyTypeEd25519();xdr.ContractDataDurability.persistent();
// After — drop the parensxdr.AssetType.assetTypeNative;xdr.ScValType.scvU32;xdr.SignerKeyType.signerKeyTypeEd25519;xdr.ContractDataDurability.persistent;Each enum member is now a static readonly instance with .name (string) and
.value (number) properties. To compare, prefer obj.type === "name" (see §
2). For raw enum equality, instances are reference-stable singletons, so ===
works.
4. Primitives: bigint and number, not class wrappers
Int64 / Uint64 are bigint. Int32 / Uint32 are number.
// Beforenew xdr.Int64("123456789101112"); // a Hyper instancenew xdr.Uint64(0); // a UnsignedHyper instancexdr.Int64.fromString("…");
// After — these all return native primitivesxdr.Int64("123456789101112"); // bigintxdr.Uint64(0); // bigintxdr.Int64.fromString("…"); // bigint
// Most call sites simply use literalsconst nonce = 12345n; // bigint literalconst fee = 100; // numbernew xdr.SorobanAddressCredentials({ nonce: 0n, … });The new xdr.Int64(v) form is not supported. It throws a TypeError
telling you to use the call form. (JavaScript can’t return a primitive from a
constructor, and a boxed bigint would fail deep inside serialization instead of
at the call site.)
5. Wide ints: bigint-direct, not LargeInt subclasses
Int128, Uint128, Int256, Uint256 are now thin classes built on the new
BigIntValue base. They hold a single value: bigint and round-trip through
the generated Int128Parts / Uint128Parts / etc. structs.
These four are reachable both as xdr.Int128 and as a top-level Int128.
It is the same class either way, and one of the few exceptions to the “XDR types
are namespace-only” rule in § 9. That matters when migrating: the legacy SDK also
exported a top-level Int128, but it was a different class (a LargeInt
subclass). So import { Int128 } from "@stellar/stellar-sdk" still resolves and
still constructs. It just builds a different object with a different
constructor contract, rather than failing at the import.
// Before — multi-arg constructors with 32- or 64-bit slicesnew xdr.Int128(lo, hi);new xdr.Int256(loLo, loHi, hiLo, hiHi);new xdr.Uint256(1n, 2n, 3n, 4n).toBigInt();i128.size; // 128i128.unsigned; // false
// After — single bigintnew xdr.Int128(42n);new xdr.Uint256(123456789n).value; // bigintxdr.Int128.fromXdrObject({ hi, lo }); // round-trip via the parts structi128.value; // biginti128.toParts(); // { hi, lo }i128.toXdr(); // 16 bytesxdr.Int128.fromJson("42"); // JSON deserializeTo reconstruct a bigint from XDR parts (the old new Int128(lo, hi).toBigInt()
pattern), use XdrLargeInt, one of the few XDR-adjacent classes exported
top-level, alongside ScInt. As in the legacy SDK, it accepts slices in
little-endian order (parts[0] is least significant):
import { XdrLargeInt } from "@stellar/stellar-sdk";
const { hi, lo } = i128.toParts(); // no `.lo` / `.hi` directly on the instancenew XdrLargeInt("i128", [lo, hi]).toBigInt();new XdrLargeInt("u256", [loLo, loHi, hiLo, hiHi]).toBigInt();XdrLargeInt also range-checks at construction (legacy LargeInt did this;
the new bigint-direct impl preserves the behavior).
new XdrLargeInt("u64", 1n << 64n) throws a RangeError, as does a slice that
doesn’t fit its width (e.g. new XdrLargeInt("u128", [0n, 2n ** 80n])).
XdrLargeInt and ScInt
ScInt’s own API is unchanged, but it extends XdrLargeInt, whose shape moved
underneath it, so inherited members changed for both classes:
.intis gone. It used to hold aLargeIntinstance (Hyper,Int128,Int256, …). It’s replaced byreadonly value: bigintplusreadonly type: ScIntType. Anything reaching through it, such asnew ScInt(x).int.toBigInt(),.int.size,.int.unsigned, or.int.slice(…), now fails withCannot read properties of undefined.valueOf()returns abigintinstead of the wrappedLargeInt. This is a silent change:xli + 1nworks where it previously didn’t, and comparisons against aLargeIntbehave differently.typeis nowreadonly, so reassigning it is a compile error.- The constructor validates
typeup front (TypeError: invalid type: …) instead of failing later, and slice input has new guards: an empty slice array throwsRangeError, and a slice count that doesn’t evenly divide the width throwsTypeError.
6. Bytes: Uint8Array and byte wrappers
Byte fields no longer surface Buffer, and they come in two shapes. An
anonymous opaque[N] / opaque<N> field is a plain Uint8Array; a named
byte alias (Hash, Signature, ScBytes, AssetCode4, PoolId,
Uint256Bytes, …) is a small class wrapping one, and .toBytes() gives you the
Uint8Array — see § 6.1. Either way the underlying bytes are a Uint8Array
where they used to be a Buffer. Buffer is a Uint8Array subclass, so
code that just reads raw byte fields (indexing, .length) keeps working.
A wrapper is not a Uint8Array, though, so the reads you’d reach for on one
don’t work:
const h = new xdr.Hash(new Uint8Array(32));h instanceof Uint8Array; // falseh.length; // undefined — likewise h[0]Array.from(h); // [] — neither iterable nor array-likeh.value; // Uint8Array(32) (h.toBytes() returns the same)strict TypeScript rejects those three reads at the call site (TS2339, TS7053,
TS2769); plain JavaScript silently gets undefined or [].
The differences from Buffer appear when:
(Note: XDR string fields are a separate story; see § 11.)
- You compare values with
toEqualor another deep equality check. Two wrappers compare with.equals(), which is class-aware (new xdr.Hash(x).equals(new xdr.PoolId(x))isfalse). It is not a general byte comparison: a raw field has no.equals()at all, and a wrapper’s.equals()returnsfalsefor raw bytes rather than throwing — so unwrap and compare withareUint8ArraysEqual()fromuint8array-extraswhen the two sides differ in shape. Don’t useArray.from()on a wrapper — it returns[]for both sides, so the assertion passes vacuously; on raw fields it’s still the usual fix forBufferandUint8Arrayof identical bytes not being deep-equal under vitest / Jest. - You call Buffer-only methods (e.g.
.toString("hex")). Wrap at the boundary:Buffer.from(uint8array).toString("hex")— unwrap a wrapper first — or useuint8array-extras. See the Uint8Array migration guide.
Passing bytes in
Byte fields still accept raw bytes, so there’s no need to wrap every call site. Both forms typecheck and encode identically:
// Equivalentxdr.ContractExecutable.contractExecutableWasm(new Uint8Array(32));xdr.ContractExecutable.contractExecutableWasm(new xdr.Hash(new Uint8Array(32)));xdr.ScVal.scvBytes(new Uint8Array([1, 2, 3]));xdr.ScVal.scvBytes(new xdr.ScBytes(new Uint8Array([1, 2, 3])));
// Struct constructors need `new` — they're classes now, not factory functionsnew xdr.LedgerKeyContractCode({ hash: someBytes });The one place a specific class is required is the named byte aliases; see § 6.1.
Byte-class constructors also accept hex strings as a convenience, so
new xdr.Hash("aabbcc…") works the same as passing 32 bytes.
6.1 Named byte aliases are classes
Every typedef opaque in the schema emits its own BytesValue subclass with a
distinct named schema. Writing is forgiving — a constructor or factory takes raw
bytes, a string, or the wrapper itself — but reading gives you the wrapper,
so unwrap with .toBytes():
// Writing — all three workxdr.PublicKey.publicKeyTypeEd25519(rawBytes);xdr.PublicKey.publicKeyTypeEd25519("3f0c34bf…");xdr.PublicKey.publicKeyTypeEd25519(new xdr.Uint256Bytes(rawBytes));
// Reading — the wrapper needs unwrappingStrKey.encodeEd25519PublicKey(key.ed25519.toBytes());The string form is hex, except for AssetCode4 / AssetCode12, which take the
code as ASCII and zero-pad it (new xdr.AssetCode4("USD")). Length is checked
at construction rather than at encode time, so a wrong-sized array throws where
you built it.
Here is the full set, and the types whose fields hand you one. .toBytes() is
what you need at every read site below:
| Class | Width | Read it from |
|---|---|---|
Hash | 32 | pervasive — ledger headers, SCP statements, ContractExecutable, ContractCodeEntry, TtlEntry, LedgerKeyContractCode, TransactionResultPair, HashIdPreimage, Memo (the memoHash / memoReturn arms), and ~25 more |
Uint256Bytes | 32 | MuxedAccount, MuxedAccountMed25519, MuxedEd25519Account, PublicKey (and its alias AccountId), SignerKey, SignerKeyEd25519SignedPayload, TransactionV0, ContractIdPreimageFromAddress, ClaimOfferAtomV0, Hello, DontHave, StellarMessage |
ContractId | 32 | ScAddress, ContractEvent, ConfigUpgradeSetKey |
PoolId | 32 | ScAddress, TrustLineAsset, LiquidityPoolEntry, LedgerKeyLiquidityPool, LiquidityPoolDepositOp, LiquidityPoolWithdrawOp, HashIdPreimageRevokeId, ClaimLiquidityAtom |
Signature | ≤64 | DecoratedSignature, ScpEnvelope, AuthCert, LedgerCloseValueSignature, the signed survey messages |
SignatureHint | 4 | DecoratedSignature |
ScBytes | unbounded | ScVal |
AssetCode4 / AssetCode12 | 4 / 12 | AssetCode, AlphaNum4, AlphaNum12 |
Thresholds | 4 | AccountEntry |
DataValue | ≤64 | DataEntry, ManageDataOp |
Value | unbounded | ScpBallot, ScpNomination |
UpgradeType | ≤128 | StellarValue |
EncodedLedgerKey | unbounded | FrozenLedgerKeys, FrozenLedgerKeysDelta |
EncryptedBody | ≤64000 | SurveyResponseMessage |
Two of these need extra care:
PoolIdandContractIdused to be plain re-exports ofHash(export const PoolId = Hash). They are now distinct classes, sonew xdr.PoolId(bytes) instanceof xdr.Hashisfalseandxdr.ScAddress.scAddressTypeContract(new xdr.Hash(bytes))no longer typechecks — passnew xdr.ContractId(bytes). This is what lets JSON output (§ 12) render aPoolIdas anL-strkey and aContractIdas aC-strkey while a plainHashstays hex. The break is type-level: at runtime all three still encode to the same 32 bytes, so plain JavaScript callers see no error.Uint256Byteswrapstypedef opaque uint256[32]. It carries theBytessuffix becausexdr.Uint256is the bigint wrapper overUint256Parts— a different type with a confusingly similar name. Watch the.valuegetter on the single-armPublicKey/AccountIdunion, which returns the wrapper too:accountId.valuebecomesaccountId.value.toBytes().
7. toXdr() / fromXdr(): simpler signatures
The legacy toXDR() (no args) returned a Buffer; toXDR("base64") returned a
string. The new toXdr() returns Uint8Array by default; toXdr("base64") or
toXdr("hex") returns a string. No more .toXDR().toString("base64")
pattern. That was a Buffer idiom and will now produce comma-separated bytes
instead of base64.
toXDR() and fromXDR() still exist on xdr.* values as deprecated aliases,
but they delegate to the new methods and share their behavior — including the
Uint8Array return above, so the .toString("base64") pattern breaks even
through the old name. Rename the calls when you touch them.
// Before — relied on Buffer.toString("base64")tx.toEnvelope().toXDR().toString("base64");
// After — pass the encoding directlytx.toEnvelope().toXdr("base64");fromXdr is symmetric:
// raw bytesxdr.Asset.fromXdr(uint8array);
// encoded string (format required)xdr.Asset.fromXdr(base64String, "base64");xdr.Asset.fromXdr(hexString, "hex");8. Immutability: fields are readonly
Every field on a generated XDR class is declared readonly, so code that
mutated XDR values after construction is now a TypeScript compile error. This is
a type-level guarantee only. Instances aren’t frozen, so plain JavaScript
callers get no error and the assignment takes effect. Either way, treat XDR
values as immutable: the setter-style call chains are gone, and mutating a
decoded value is no longer a supported way to change one.
// Before — mutating the envelope post-build workedconst envelope = tx.toEnvelope();envelope.v1().tx().fee(1000);envelope.signatures().push(decoratedSig);
// After — build a fresh envelope with the desired stateconst newEnvelope = xdr.TransactionEnvelope.envelopeTypeTx( new xdr.TransactionV1Envelope({ tx: new xdr.Transaction({ …baseTx, fee: 1000 }), signatures: [...baseEnv.signatures, decoratedSig], }),);Transaction.toEnvelope() returns a fresh, decoded copy on every call, so the
legacy “defensive copy” tests still pass, but you can no longer rely on
post-build mutation.
9. Imports and generated docs
Where to import from
Exactly as in the legacy SDK, all XDR types live on the named xdr namespace
export. They are deliberately not exported top-level, because names like Asset,
Memo, and Operation at the top level are the SDK wrapper classes, which
would collide:
import { xdr } from "@stellar/stellar-sdk";
xdr.Asset.assetTypeNative();xdr.ScVal.scvU32(42);
import { Asset } from "@stellar/stellar-sdk";// ⚠ This is the SDK's Asset wrapper class, NOT xdr.Asset — same as before.The exceptions are Int128, Uint128, Int256, and Uint256, which are
exported top-level as well as on xdr (§ 5), plus the XDR-adjacent
XdrLargeInt and ScInt, which are top-level only.
Variant-class types
Each union variant ships as its own class (and TS type) on the same xdr
namespace. Use the qualified name in annotations or as casts:
import { xdr } from "@stellar/stellar-sdk";
const v1 = (env as xdr.TransactionEnvelopeTx).v1;const addr: xdr.ScValAddress = xdr.ScVal.scvAddress(scAddress);Generated TSDoc
Every generated class carries its original .x source as a TSDoc comment, so
hovering over a type in your IDE shows the upstream Stellar XDR definition:
/** * ```xdr * struct SCPBallot * { * uint32 counter; // n * Value value; // x * }; * ``` */export class ScpBallot extends XdrValue { … }10. Quick reference
// ============== UNIONS ==============
// switch → typeop.body().switch().name → op.body.typeop.body().switch() === T.X() → op.body.type === "x"
// value → value (no parens)scv.value() → scv.value
// arm gettersasset.alphaNum4() → asset.alphaNum4 (after narrowing)
// constructors → factoriesnew xdr.AccountEntryExt(0) → xdr.AccountEntryExt.v0()new xdr.TransactionMeta(2,x) → xdr.TransactionMeta.v2(x)
// ============== ENUMS ==============xdr.AssetType.assetTypeNative() → xdr.AssetType.assetTypeNative
// ============== PRIMITIVES ==============new xdr.Int64(v) → BigInt(v) or v + "n" literalnew xdr.Uint64(v) → BigInt(v)new xdr.Int32(v) → Number(v)
// ============== METHODS (renames) ==============// Every legacy all-caps spelling (toXDR/fromXDR/validateXDR/toXDRObject/…)// survives as a deprecated alias — on xdr.* values with the new Uint8Array// semantics. Note xdr.* values' toXdrObject/fromXdrObject are net-new and// never had an all-caps form..toXDR() → .toXdr().toXDR().toString("base64") → .toXdr("base64").fromXDR(buf, "base64") → .fromXdr(buf, "base64").validateXDR(s, "base64") → .validateXdr(s, "base64")
// ============== METHODS (new — no legacy equivalent) ============== .toXdrObject() / .fromXdrObject(wire) .toJson() / .fromJson(json)
// ============== BYTES ==============contractExecutableWasm(buf) → (unchanged; raw bytes still accepted)scvBytes(buf) → (unchanged; raw bytes still accepted)new xdr.Hash(buf) → (still works; also accepts hex strings)new xdr.Hash(bytes) for PoolId/ContractId → use new xdr.PoolId(bytes) / new xdr.ContractId(bytes)// Reading out: the 15 named byte aliases (Hash, Signature, ScBytes, …) wrapsomeHash → someHash.toBytes()key.ed25519, preimage.salt → key.ed25519.toBytes(), preimage.salt.toBytes() // uint256 fields are xdr.Uint256Bytes nowhash.length, hash[0], Array.from(hash) → hash.toBytes()deepEqual(Array.from(a), …) → a.equals(b) // both wrappers only
// ============== STRINGS ==============memo.text → memo.text.toString() or memo.text.bytesmemo.value (memoText, after → (Uint8Array; was string) — decode with Memo.fromXdrObject) uint8ArrayToString(), NOT .toString("utf8")scvString.str (was string) → scvString.str.bytes (or scvString.value: string)
// ============== JSON (new) ============== value.toJson() // SEP-0051 encode Type.fromJson(json) // SEP-0051 decode
// ============== WIDE INTS ==============new xdr.Int128(lo, hi) → new XdrLargeInt("i128", [lo, hi])new xdr.Int256(loLo, …) → new XdrLargeInt("i256", [loLo, …, hiHi])new xdr.Int128(42n).toBigInt() → new xdr.Int128(42n).valuei128.lo, i128.hi → i128.toParts() // { hi, lo }i128.size, i128.unsigned → (no longer exposed — pick the right class)scInt.int / xli.int → scInt.value / xli.value (bigint) — `.int` is gone
// ============== OPTIONALS (§ 14) ==============x === undefined → x == null // decoded absent = null now
// ============== REMOVED (§ 13) ==============xdr.scvSortedMap(entries) → scvSortedMap(entries) // top-level exportxdr.Hyper / xdr.Option / xdr.Opaque / xdr.XDRString / … → (gone; see § 13)Hyper, UnsignedHyper, cereal → (gone from top-level)
// ============== TYPE NAMES (§ 1.1) ==============UInt128Parts, UInt256Parts → Uint128Parts, Uint256PartsThresholdIndices → ThresholdIndexesDuration, TimePoint, SequenceNumber → bigintScVec, ScMap → xdr.ScVal[], xdr.ScMapEntry[]ScString, ScSymbol, String32/64 → xdr.XdrStringSponsorshipDescriptor → xdr.AccountId | null
// Struct field names (accountId, offerId, sponsoredId, …) and most type names// (AccountId, ScVal, TtlEntry, …) are unchanged.11. Strings: the XdrString wrapper
XDR string<N> fields no longer surface as JavaScript string. They’re wrapped
in a new XdrString class. The reason: a JS string can’t be both
byte-faithful and text-friendly (it’s UTF-16 internally with no clean
representation for arbitrary byte sequences), and Stellar’s wire format puts
arbitrary bytes in some string<N> fields, notably MemoText, where real
envelopes on mainnet carry binary tokens, signatures, and other non- UTF-8
content. XdrString stores the wire bytes as the canonical representation and
lets the caller choose decoding semantics explicitly.
Affects any field declared as string<N> in the XDR, including
MemoText.text, ScValString.str, ScValSymbol.sym,
SetOptionsOp.homeDomain, ManageDataOp.dataName,
InvokeContractArgs.functionName, every ScSpec*.name, and similar.
Construction
XdrString and the union/struct constructors that wrap it accept three input
shapes:
import { xdr } from "@stellar/stellar-sdk";
new xdr.XdrString("hello"); // string → UTF-8 encodednew xdr.XdrString(new Uint8Array([0xd1, 0xff])); // bytes → byte-exactnew xdr.XdrString(otherXdrString); // copy
// Generated factories accept the same union shape:xdr.Memo.memoText("hello"); // stringxdr.Memo.memoText(new Uint8Array([0xd1, 0xff])); // bytesxdr.ScVal.scvSymbol("transfer"); // stringReading values
Pick the access pattern that matches what you want:
const text: xdr.XdrString = (memo as xdr.MemoText).text;
text.bytes; // Uint8Array — canonical wire formtext.toString(); // "hello" — UTF-8 decode; U+FFFD on invalidtext.toStringStrict(); // "hello" — throws on invalid UTF-8text.asStringOrBytes(); // string | Uint8Array — best-effort decodetext.toJson(); // SEP-0051 escape form (see § 12)text.length; // byte lengthtext.equals(other); // byte-equal comparison.toString() is the default JS string coercion, so ${memo.text} works
naturally for ASCII / UTF-8 content. It will not throw on binary bytes;
invalid sequences become U+FFFD. If you want a hard failure on malformed UTF-8,
use .toStringStrict().
The .value getter on union arms
For union arms whose payload is string<N> (e.g. ScValString, ScValSymbol,
MemoText), the .value getter returns the decoded JS string, not the
XdrString. The arm-named field exposes the raw wrapper:
const scv = xdr.ScVal.scvString("hi");
scv.value; // "hi" — decoded string (was previously `string`-typed; unchanged)scv.str; // XdrString { bytes: Uint8Array(2) [0x68, 0x69] }scv.str.bytes; // Uint8ArrayThis split keeps the .value shortcut convenient for the 99% case while still
letting binary callers reach the raw bytes through the arm field.
The decode is specific to string<N> arms, so don’t generalize it to the rest
of xdr.Memo: memoHash and memoReturn hand back an xdr.Hash wrapper from
both the arm field and .value, and need .toBytes() (§ 6.1).
| Arm | Arm field | You get |
|---|---|---|
memoText | text | XdrString; .value is the decoded string |
memoId | id | bigint |
memoHash | hash | xdr.Hash — .value is the wrapper too |
memoReturn | retHash | xdr.Hash — .value is the wrapper too |
The SDK-level Memo class is different again: it unwraps, so
Memo.fromXdrObject(...).value for a hash memo is a Uint8Array — see the
round-trip caveat below.
Round-trip caveat: Memo.fromXdrObject
The SDK-level Memo class (in src/base/memo.ts) now surfaces decoded
MemoText content as a Uint8Array, not a string, because the underlying
bytes might not be valid UTF-8:
import { uint8ArrayToString } from "uint8array-extras";
const wire = xdr.Memo.memoText("hi").toXdr();const back = Memo.fromXdrObject(xdr.Memo.fromXdr(wire));back.value; // Uint8Array [0x68, 0x69] (was: string "hi")uint8ArrayToString(back.value); // "hi" — explicit decode at the boundaryIf you previously did someMemo.value === "expected-string", switch to
someMemo.value && uint8ArrayToString(someMemo.value) === "expected-string".
Don’t reach for
.toString("utf8"). It’s aBuffermethod, andUint8Array.prototype.toStringignores the argument, so you get"104,105", a comma-joined byte list, and nothing throws. See the Uint8Array migration guide for the full set ofBuffer-method replacements.
Note that this only affects the decode path. A Memo you constructed yourself
(Memo.text("hi")) still holds the string you passed.
12. SEP-0051 JSON output: toJson() / fromJson()
Every generated XDR class has SEP-51-compliant JSON serialization built in. New in this release; no legacy equivalent.
// Encode any XDR value to JSONxdr.Asset.assetTypeNative().toJson();// → "native"
xdr.ScVal.scvI128(new xdr.Int128Parts({ hi: 0n, lo: 12345n })).toJson();// → { i128: "12345" }
xdr.ScAddress.scAddressTypeAccount(pubkey).toJson();// → "GAAQEAYEAUDAOCAJBIFQYDIO…" (StrKey)
// Round-tripconst json = original.toJson();const recovered = xdr.Asset.fromJson(json);recovered.toXdr(); // byte-identical to original.toXdr()Shape conventions
- Unions, void arm: snake_case case-name string.
Memo.memoNone()→"none".Asset.assetTypeNative()→"native". - Unions, non-void arm: single-key object.
Asset.assetTypeCreditAlphanum4(...)→{ credit_alphanum4: {...payload...} }. - Unions switched on an integer (not an enum): discriminant retained as
v<N>.SorobanTransactionMetaExt.v0()→"v0". - Structs: object with snake_case keys.
AlphaNum4→{ asset_code: "USD", issuer: "GAAQ…" }. - Enums: snake_case member name with the common prefix stripped.
AssetType.assetTypeCreditAlphanum4→"credit_alphanum4". int32/uint32: JSON number.int64/uint64: decimal string (to avoid JS precision loss).bool: JSON boolean.opaque[N]/opaque<N>: lowercase hex string.string<N>: SEP-0051 escape form, with printable ASCII pass-through,\0\t\n\r\\for the common control bytes,\xNNfor everything else. Reversible.- Optional
T?: JSONnullwhen unset, typed value when set.
Stellar-specific JSON forms (overrides)
Several types have spec-mandated JSON forms that the walker dispatches on the schema name:
| Type | JSON form |
|---|---|
PublicKey, AccountId, NodeId | G-strkey |
MuxedAccount (muxed arm), MuxedEd25519Account | M-strkey |
ContractId, ScAddress (contract arm) | C-strkey |
PoolId, ScAddress (liquidity_pool arm) | L-strkey |
ClaimableBalanceId, ScAddress (claimable_balance arm) | B-strkey |
SignerKey preAuthTx / hashX / ed25519SignedPayload | T/X/P-strkey |
Int128Parts, Uint128Parts, Int256Parts, Uint256Parts | decimal string |
AssetCode4 | trimmed text (trailing zero bytes removed) |
AssetCode12 | trimmed text, minimum 5 bytes (so it’s distinguishable from AssetCode4) |
fromJson accepts SEP-0051 keys only
fromJson accepts the SEP-0051 form and nothing else. The raw wire field names
(camelCase) are not accepted, and an unknown struct key is rejected rather
than ignored. An omitted optional field decodes to null, so a silently
skipped typo would drop data without any error.
xdr.AlphaNum4.fromJson({ asset_code: "USD", issuer: "GAAQ…" }); // okxdr.AlphaNum4.fromJson({ assetCode: "USD", issuer: "GAAQ…" }); // throws: unknown field assetCodexdr.AlphaNum4.fromJson({ asset_code: "USD", issuer: "GAAQ…", extra: 1 }); // throws: unknown field extraThe same applies to enum and union case names. Only the snake_case, prefix-stripped spelling works:
xdr.ScValType.fromJson("i32"); // okxdr.ScValType.fromJson("scvI32"); // throws: unknown enum name scvI32xdr.Asset.fromJson({ credit_alphanum4: payload }); // okxdr.Asset.fromJson({ assetTypeCreditAlphanum4: payload }); // throws: unknown caseOne exception: for struct fields whose name is a Rust keyword, the Rust
stellar-xdr crate emits a keyword-escaped key (type_ instead of type).
fromJson accepts that legacy spelling alongside the plain one so JSON from the
Rust tooling still parses. Supplying both spellings of the same field is
ambiguous and throws.
xdr.ContractEvent.fromJson({ ...rest, type: "contract" }); // ok (canonical)xdr.ContractEvent.fromJson({ ...rest, type_: "contract" }); // ok (Rust legacy)xdr.ContractEvent.fromJson({ ...rest, type: "contract", type_: "contract" }); // throwsMethod names
value.toJson()JSON-encodes. It returns the parsed JSON value (object, array, string, number, boolean, ornull). UseJSON.stringify(...)if you want a string.Type.fromJson(json)JSON-decodes. It accepts the same shapetoJsonproduces and throws on malformed structure.value.toJSON()(capital JSON) is the JavaScript-standard hook called byJSON.stringify; it delegates totoJson(), soJSON.stringify(value) === JSON.stringify(value.toJson()). CalltoJson()in your own code; the hook exists for implicit serialization (loggers,res.json, snapshots). To substitute a custom encoding underJSON.stringify, use a replacer function (itsthis[key]is the original instance, before the hook fired).
13. Removed exports
The SDK’s XDR runtime is now @stellar/js-xdr v5 (previously v4), and the SDK
no longer exports Reader or Writer, which are internal runtime details.
For decoding and encoding, use Type.fromXdr(...) and value.toXdr(...) (see §
7). For the main legacy Reader use case, several values of one type
concatenated in a single buffer, use the new xdr.decodeStream helper:
import { xdr } from "@stellar/stellar-sdk";
// Uint8Array containing N back-to-back ScSpecEntry values:const entries = xdr.decodeStream(xdr.ScSpecEntry, bytes);decodeStream decodes until the buffer is exhausted and throws if the remaining
bytes don’t form a complete value. It never returns a partial list.
For a length-prefixed XDR variable-length array (the wire format of the removed
array typedefs — a 4-byte count, then the elements), use xdr.encodeArray and
xdr.decodeArray instead (§ 1).
Runtime type constructors
The v4 schema/authoring types the legacy SDK re-exported on xdr are gone, as
values and as types: Bool, Hyper, UnsignedHyper, SignedInt,
UnsignedInt, Opaque, VarOpaque, Option, XDRArray, and XDRString. The
new layer’s generated classes and native primitives (§ 4, § 6, § 11) replace
them; the schema builders behind them are internal.
Hyper and UnsignedHyper were also exported top-level, as was cereal
(the raw js-xdr namespace). All three are gone. For 64-bit values use bigint
(§ 4).
validateXDR and xdr.scvSortedMap
-
validateXDR(input, format)was a static on every generated type — a “is this decodable?” check. It survives asvalidateXdr(casing now matchesfromXdr/toXdr):Uint8Arrayinput, or a string with"hex" | "base64". As withfromXdr, the optional"raw"format argument is gone — pass bytes alone (§ 7). It does a full decode and returns a boolean; it never throws. When you need the failure reason, callfromXdrin atry/catchinstead (§ 15).// Beforeif (xdr.TransactionEnvelope.validateXDR(str, "base64")) { … }// Afterif (xdr.TransactionEnvelope.validateXdr(str, "base64")) { … } -
xdr.scvSortedMap()is gone. The legacy SDK monkey-patched it onto thexdrnamespace for backwards compatibility; the new layer doesn’t. Use the top-level export, which was always the real home:import { scvSortedMap } from "@stellar/stellar-sdk".
If you depended on the Reader/Writer previously obtained through the SDK,
depend on @stellar/js-xdr directly instead. Note that v5’s Reader/Writer
API differs from v4’s (the wire format is unchanged); if you still need the v4
runtime for legacy code, install it under an alias:
"dependencies": { "js-xdr-v4": "npm:@stellar/js-xdr@4.0.0"}14. Optional fields: null, not undefined
An absent optional (T* in the XDR) now decodes to null. The legacy layer
used undefined:
// Legacy: an unset optional read back as undefinedtx.cond().v2().timeBounds(); // undefined
// Now: nullxdr.PreconditionsV2.fromXdr(bytes).timeBounds; // nullThis is a silent change. The shape of the check is what breaks, not the type:
// ⚠ Compiles, never matches any moreif (v2.timeBounds === undefined) { … }
// ⚠ Worse: the guard passes for null, then the access throwsif (v2.timeBounds !== undefined) { v2.timeBounds.minTime; // TypeError: … of null}
// ✅ Covers bothif (v2.timeBounds == null) { … }if (!v2.timeBounds) { … }Construct absent fields with null, not undefined. TypeScript already
requires it (timeBounds: TimeBounds | null), and the runtime holds you to it,
though where it does so depends on the field’s type:
- Struct- or union-typed optionals (
TimeBounds*,AccountId*) store whatever you pass, so an instance still builds;toXdr()/toXdrObject()then throws aTypeError(“Cannot read properties of undefined”). - Primitive optionals (
uint32*,int64*) likewise build, then throw anxdr.XdrErrorat encode time naming the field. string<N>and byte-typedef optionals (homeDomain,DataValue*) coerce their input in the constructor, so they throw aTypeErrorright there —new xdr.SetOptionsOp({})never reaches an encode call.
Decoded values are always null. Prefer == null / falsy checks over
=== undefined everywhere.
In JSON output an unset optional is null too (§ 12).
15. Errors and validation
Malformed data in the XDR layer throws XdrError, which the SDK exports,
so you can finally match on the class instead of on message text. Arguments of
the wrong type are caller errors and throw a TypeError.
import { xdr } from "@stellar/stellar-sdk";
try { xdr.TransactionEnvelope.fromXdr(input, "base64");} catch (e) { if (e instanceof xdr.XdrError) { … }}Two things to know when migrating:
-
Every message changed. The v4 runtime threw
XdrReaderError/XdrWriterError(neither of which the SDK exported, so message-matching was the only option) with text like"XDR Write Error: invalid i32 value". The equivalent is now"Uint32: value 4294967296 out of range [0, 4294967295]". Anycatchblock matching on error text needs rewriting. -
XdrErrorcovers thexdrnamespace only. A few SDK entry points decode base64 or hex themselves, before thexdrlayer sees the bytes, and surface their decoder’s error instead:new Transaction(envelope),new FeeBumpTransaction(envelope), andtx.addSignature(key, sig)decode base64 with the platformatob, which Node wordsDOMException: Invalid characterand other runtimes word differently.Operation.setOptions’s hex signer keys (sha256Hash,preAuthTx) decode withuint8array-extras, which throws a plainErrorreadingInvalid Hex character encountered at position 0— the same wording on every runtime.
Entry points that hand the string straight to the
xdrlayer do throwXdrError, includingTransactionBuilder.fromXdrandSorobanDataBuilder’s constructor andfromXdr.
Strictness itself is mostly unchanged. v4 also rejected buffers it didn’t fully
consume ("source buffer not entirely consumed"), which is now
"Asset: trailing 4 byte(s) after XDR value". Byte-length mismatches on
fixed-size fields, out-of-range integers, unknown union discriminants, and
unknown enum values all throw as before, with new wording.
validateXDR survives as validateXdr (casing matches fromXdr/toXdr) —
see § 13. It returns a bare boolean; decode directly when you need the error.
Enum lookup
Enums keep name- and value-based lookup, useful when decoding from external input:
xdr.AssetType.fromName("assetTypeCreditAlphanum4");xdr.AssetType.fromValue(1);16. Changes outside the xdr namespace
The XDR swap changed a few SDK-level behaviors that have nothing to do with
typing xdr. yourself. These are the easiest ones to miss because most produce
no error at all.
scValToNative may return bytes for a string
An scvString whose contents aren’t valid UTF-8 now comes back as a
Uint8Array instead of a lossily-decoded string:
scValToNative(xdr.ScVal.scvString("hello")); // "hello" (string, as before)scValToNative(xdr.ScVal.scvString(rawBytes)); // Uint8Array (was a U+FFFD string)The legacy implementation looked like it did this, but its byte-returning branch
was unreachable: it decoded with a non-fatal TextDecoder, which never throws.
So in practice legacy always returned a string. Code doing
result.startsWith(…), result.trim(), result.length, or
typeof result === "string" on contract output is now data-dependent.
scvSymbol follows the same rule, but it can’t reach the byte-returning branch
in practice: the host restricts symbols to [_0-9A-Za-z] and at most 32 bytes,
rejecting anything else with Error(Value, InvalidInput), so a symbol that came
off the network is always valid UTF-8.
The same applies to contract.Spec.scValToNative and funcResToNative for
Bytes / BytesN, which return Uint8Array; because those are generically
typed (T), TypeScript won’t flag it.
homeDomain and data-entry names decode as UTF-8, not ASCII
Operation.fromXdrObject used .toString("ascii"), which masks every byte to 7
bits. It now decodes UTF-8 leniently, so bytes ≥ 0x80 read back differently:
// wire dataName bytes: [0xC3, 0xA9]rec.name; // "é" — was "C)"Affects manageData’s name, setOptions’s homeDomain, and
revokeSponsorship’s data-entry name. No valid operation is affected.
stellar-core’s
isStringValid
rejects any byte outside 0x20–0x7E in all three, and ASCII and UTF-8 agree
over that range. Only synthetic or hand-forged XDR decodes differently.
For such input the new decode round-trips valid UTF-8 (the legacy pairing of
ASCII reads with UTF-8 writes did not) but still loses bytes that aren’t valid
UTF-8, which become U+FFFD. If you need byte fidelity, read the XdrString
instead of going through Operation.fromXdrObject: attrs.homeDomain.bytes,
.asStringOrBytes() to branch, or .toStringStrict() to throw.
SorobanDataBuilder rebuilds instead of mutating
Setters used to mutate one internal object; they now replace it. Any value you captured earlier is a stale snapshot:
const fp = builder.getFootprint();builder.setReadOnly(keys);fp.readOnly.length; // 0 — the builder moved on without itRe-read from the builder after each call, and note that the legacy
mutate-through idiom (builder.getFootprint().readOnly(keys)) is gone, because
footprint fields are readonly arrays now.
MuxedAccount.setId no longer mutates a handed-out object
This section covers the top-level MuxedAccount helper class. The XDR union
xdr.MuxedAccount has no setter methods, and its fields are declared
readonly.
import { Account, MuxedAccount } from "@stellar/stellar-sdk";
const muxed = new MuxedAccount(new Account(pubKey, "1"), "5");const held = muxed.toXdrObject(); // an xdr.MuxedAccountmuxed.setId("99");
// `toXdrObject()` returns the `MuxedAccount` union, so narrow before// reading the arm (see § on union access).if (held.type === "keyTypeMuxedEd25519") { held.med25519.id; // 5n — still the old id}setId replaces the internal XDR object instead of mutating it in place, so
call toXdrObject() again afterwards.
How to check for the two cases above
The legacy mutate-through idiom fails loudly. Those accessors were methods, and they no longer exist:
held.med25519().id(99n);// tsc: error TS2339: Property 'med25519' does not exist on type 'MuxedAccount'// runtime: TypeError: held.med25519 is not a functionTypeScript users get a build error; plain-JS users get a TypeError the first
time the line runs. Neither can keep mutating unnoticed. (A direct field write,
held.med25519.id = 99n, is a readonly error in TypeScript. Nothing is frozen
at runtime, though, so from JS the write lands and silently desyncs the helper’s
cached id() and M-address.)
What is silent is narrower: capture a reference, call a mutator, then read the
stale capture. Seven methods can do it: MuxedAccount.setId, plus
SorobanDataBuilder.setResourceFee, setResources, appendFootprint,
setFootprint, setReadOnly, and setReadWrite. Only MuxedAccount.toXdrObject()
and SorobanDataBuilder.getFootprint() (and getReadOnly /
getReadWrite, which delegate to it) hand out a reference that can go stale;
SorobanDataBuilder.build() returns a clone, so the normal build path is
unaffected.
tsc --noEmit # catches every mutate-through
rg -n '\.setId\(|\.set(ResourceFee|Resources|Footprint|ReadOnly|ReadWrite)\(|\.appendFootprint\('For each hit, look back for a toXdrObject() or getFootprint() result stored
in a variable and forward for a read of it. If the getter result is used
immediately, which is the usual shape, there is nothing to fix.
Byte-typed results
getLiquidityPoolId(), AuthEntrySignature.signature, and the second argument
handed to a SigningCallback are all Uint8Array now. .toString("hex") on
any of them silently yields comma-joined decimals. See the
Uint8Array migration guide.
DecoratedSignature.signature and .hint — what tx.signatures[i] holds — are
not raw bytes, despite the name the first shares with
AuthEntrySignature.signature. They are xdr.Signature / xdr.SignatureHint
wrappers; unwrap with .toBytes(). Both were a Buffer through 16.2.0. The
rule is the XDR declaration, not the field name: a named byte typedef is
wrapped — uint256 included, as xdr.Uint256Bytes — while an anonymous inline
opaque field is raw (§ 6).