Skip to content

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:

BeforeAfter
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:

ClassRenamed
Transaction, FeeBumpTransactiontoXDR()toXdr()
TransactionBuilderfromXDR()fromXdr()
contract.AssembledTransactiontoXDR() / fromXDR()toXdr() / fromXdr()
SorobanDataBuilderfromXDR()fromXdr()
AssettoXDRObject()toXdrObject()
MemotoXDRObject() / fromXDRObject()toXdrObject() / fromXdrObject()
OperationfromXDRObject()fromXdrObject()
ClaimanttoXDRObject()toXdrObject(), fromXDR()fromXdr()
MuxedAccounttoXDRObject()toXdrObject()
LiquidityPoolAsset, LiquidityPoolIdtoXDRObject()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:

MethodDescription
value.toXdrObject() on XDR valuesBridges 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 wrappersUnwraps 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:

LegacyNow
UInt128PartsUint128Parts
UInt256PartsUint256Parts
ThresholdIndicesThresholdIndexes

And the following typedef aliases are gone entirely. The new layer inlines what they stood for rather than exporting a name for it:

Legacy aliasWasNow write
Duration, TimePointUint64bigint
SequenceNumberInt64bigint
ScVecarray of ScValxdr.ScVal[]
ScMaparray of ScMapEntryxdr.ScMapEntry[]
LedgerEntryChangesarray of LedgerEntryChangexdr.LedgerEntryChange[]
ContractCostParamsarray of ContractCostParamEntryxdr.ContractCostParamEntry[]
SorobanAuthorizationEntriesarray of SorobanAuthorizationEntryxdr.SorobanAuthorizationEntry[]
ScString, ScSymbol, String32, String64XDRStringxdr.XdrString (§ 11)
SponsorshipDescriptorundefined | AccountIdxdr.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)

// Before
if (op.body().switch() === xdr.OperationType.payment()) { … }
// After
if (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

// Before
const scv = xdr.ScVal.scvU32(42);
scv.value(); // 42
scv.switch(); // xdr.ScValType.scvU32()
const asset = ...; // xdr.Asset
asset.alphaNum4(); // returns the AlphaNum4 payload
// After
const 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:

// Before
new 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);
// After
xdr.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 built
from per-variant factories. Call xdr.TransactionMeta.operations(...) (or another
arm 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 mismatch
const 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 branch
if (xdr.isUnionVariant(scv, "scvU32")) {
scv.u32; // number
}

3. Enums: singletons, not factory calls

// Before — factory-call returning an enum singleton
xdr.AssetType.assetTypeNative();
xdr.ScValType.scvU32();
xdr.SignerKeyType.signerKeyTypeEd25519();
xdr.ContractDataDurability.persistent();
// After — drop the parens
xdr.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.

// Before
new xdr.Int64("123456789101112"); // a Hyper instance
new xdr.Uint64(0); // a UnsignedHyper instance
xdr.Int64.fromString("");
// After — these all return native primitives
xdr.Int64("123456789101112"); // bigint
xdr.Uint64(0); // bigint
xdr.Int64.fromString(""); // bigint
// Most call sites simply use literals
const nonce = 12345n; // bigint literal
const fee = 100; // number
new 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 slices
new xdr.Int128(lo, hi);
new xdr.Int256(loLo, loHi, hiLo, hiHi);
new xdr.Uint256(1n, 2n, 3n, 4n).toBigInt();
i128.size; // 128
i128.unsigned; // false
// After — single bigint
new xdr.Int128(42n);
new xdr.Uint256(123456789n).value; // bigint
xdr.Int128.fromXdrObject({ hi, lo }); // round-trip via the parts struct
i128.value; // bigint
i128.toParts(); // { hi, lo }
i128.toXdr(); // 16 bytes
xdr.Int128.fromJson("42"); // JSON deserialize

To 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 instance
new 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:

  • .int is gone. It used to hold a LargeInt instance (Hyper, Int128, Int256, …). It’s replaced by readonly value: bigint plus readonly type: ScIntType. Anything reaching through it, such as new ScInt(x).int.toBigInt(), .int.size, .int.unsigned, or .int.slice(…), now fails with Cannot read properties of undefined.
  • valueOf() returns a bigint instead of the wrapped LargeInt. This is a silent change: xli + 1n works where it previously didn’t, and comparisons against a LargeInt behave differently.
  • type is now readonly, so reassigning it is a compile error.
  • The constructor validates type up front (TypeError: invalid type: …) instead of failing later, and slice input has new guards: an empty slice array throws RangeError, and a slice count that doesn’t evenly divide the width throws TypeError.

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; // false
h.length; // undefined — likewise h[0]
Array.from(h); // [] — neither iterable nor array-like
h.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 toEqual or another deep equality check. Two wrappers compare with .equals(), which is class-aware (new xdr.Hash(x).equals(new xdr.PoolId(x)) is false). It is not a general byte comparison: a raw field has no .equals() at all, and a wrapper’s .equals() returns false for raw bytes rather than throwing — so unwrap and compare with areUint8ArraysEqual() from uint8array-extras when the two sides differ in shape. Don’t use Array.from() on a wrapper — it returns [] for both sides, so the assertion passes vacuously; on raw fields it’s still the usual fix for Buffer and Uint8Array of 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 use uint8array-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:

// Equivalent
xdr.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 functions
new 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 work
xdr.PublicKey.publicKeyTypeEd25519(rawBytes);
xdr.PublicKey.publicKeyTypeEd25519("3f0c34bf…");
xdr.PublicKey.publicKeyTypeEd25519(new xdr.Uint256Bytes(rawBytes));
// Reading — the wrapper needs unwrapping
StrKey.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:

ClassWidthRead it from
Hash32pervasive — ledger headers, SCP statements, ContractExecutable, ContractCodeEntry, TtlEntry, LedgerKeyContractCode, TransactionResultPair, HashIdPreimage, Memo (the memoHash / memoReturn arms), and ~25 more
Uint256Bytes32MuxedAccount, MuxedAccountMed25519, MuxedEd25519Account, PublicKey (and its alias AccountId), SignerKey, SignerKeyEd25519SignedPayload, TransactionV0, ContractIdPreimageFromAddress, ClaimOfferAtomV0, Hello, DontHave, StellarMessage
ContractId32ScAddress, ContractEvent, ConfigUpgradeSetKey
PoolId32ScAddress, TrustLineAsset, LiquidityPoolEntry, LedgerKeyLiquidityPool, LiquidityPoolDepositOp, LiquidityPoolWithdrawOp, HashIdPreimageRevokeId, ClaimLiquidityAtom
Signature≤64DecoratedSignature, ScpEnvelope, AuthCert, LedgerCloseValueSignature, the signed survey messages
SignatureHint4DecoratedSignature
ScBytesunboundedScVal
AssetCode4 / AssetCode124 / 12AssetCode, AlphaNum4, AlphaNum12
Thresholds4AccountEntry
DataValue≤64DataEntry, ManageDataOp
ValueunboundedScpBallot, ScpNomination
UpgradeType≤128StellarValue
EncodedLedgerKeyunboundedFrozenLedgerKeys, FrozenLedgerKeysDelta
EncryptedBody≤64000SurveyResponseMessage

Two of these need extra care:

  • PoolId and ContractId used to be plain re-exports of Hash (export const PoolId = Hash). They are now distinct classes, so new xdr.PoolId(bytes) instanceof xdr.Hash is false and xdr.ScAddress.scAddressTypeContract(new xdr.Hash(bytes)) no longer typechecks — pass new xdr.ContractId(bytes). This is what lets JSON output (§ 12) render a PoolId as an L-strkey and a ContractId as a C-strkey while a plain Hash stays hex. The break is type-level: at runtime all three still encode to the same 32 bytes, so plain JavaScript callers see no error.
  • Uint256Bytes wraps typedef opaque uint256[32]. It carries the Bytes suffix because xdr.Uint256 is the bigint wrapper over Uint256Parts — a different type with a confusingly similar name. Watch the .value getter on the single-arm PublicKey / AccountId union, which returns the wrapper too: accountId.value becomes accountId.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 directly
tx.toEnvelope().toXdr("base64");

fromXdr is symmetric:

// raw bytes
xdr.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 worked
const envelope = tx.toEnvelope();
envelope.v1().tx().fee(1000);
envelope.signatures().push(decoratedSig);
// After — build a fresh envelope with the desired state
const 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 → type
op.body().switch().name → op.body.type
op.body().switch() === T.X() → op.body.type === "x"
// value → value (no parens)
scv.value() → scv.value
// arm getters
asset.alphaNum4() → asset.alphaNum4 (after narrowing)
// constructors → factories
new 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" literal
new 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, …) wrap
someHash → someHash.toBytes()
key.ed25519, preimage.salt → key.ed25519.toBytes(), preimage.salt.toBytes()
// uint256 fields are xdr.Uint256Bytes now
hash.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.bytes
memo.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).value
i128.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 export
xdr.Hyper / xdr.Option / xdr.Opaque / xdr.XDRString / … → (gone; see § 13)
Hyper, UnsignedHyper, cereal → (gone from top-level)
// ============== TYPE NAMES (§ 1.1) ==============
UInt128Parts, UInt256Parts → Uint128Parts, Uint256Parts
ThresholdIndices → ThresholdIndexes
Duration, TimePoint, SequenceNumber → bigint
ScVec, ScMap → xdr.ScVal[], xdr.ScMapEntry[]
ScString, ScSymbol, String32/64 → xdr.XdrString
SponsorshipDescriptor → 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 encoded
new xdr.XdrString(new Uint8Array([0xd1, 0xff])); // bytes → byte-exact
new xdr.XdrString(otherXdrString); // copy
// Generated factories accept the same union shape:
xdr.Memo.memoText("hello"); // string
xdr.Memo.memoText(new Uint8Array([0xd1, 0xff])); // bytes
xdr.ScVal.scvSymbol("transfer"); // string

Reading values

Pick the access pattern that matches what you want:

const text: xdr.XdrString = (memo as xdr.MemoText).text;
text.bytes; // Uint8Array — canonical wire form
text.toString(); // "hello" — UTF-8 decode; U+FFFD on invalid
text.toStringStrict(); // "hello" — throws on invalid UTF-8
text.asStringOrBytes(); // string | Uint8Array — best-effort decode
text.toJson(); // SEP-0051 escape form (see § 12)
text.length; // byte length
text.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; // Uint8Array

This 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).

ArmArm fieldYou get
memoTexttextXdrString; .value is the decoded string
memoIdidbigint
memoHashhashxdr.Hash.value is the wrapper too
memoReturnretHashxdr.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 boundary

If you previously did someMemo.value === "expected-string", switch to someMemo.value && uint8ArrayToString(someMemo.value) === "expected-string".

Don’t reach for .toString("utf8"). It’s a Buffer method, and Uint8Array.prototype.toString ignores the argument, so you get "104,105", a comma-joined byte list, and nothing throws. See the Uint8Array migration guide for the full set of Buffer-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 JSON
xdr.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-trip
const 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, \xNN for everything else. Reversible.
  • Optional T?: JSON null when 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:

TypeJSON form
PublicKey, AccountId, NodeIdG-strkey
MuxedAccount (muxed arm), MuxedEd25519AccountM-strkey
ContractId, ScAddress (contract arm)C-strkey
PoolId, ScAddress (liquidity_pool arm)L-strkey
ClaimableBalanceId, ScAddress (claimable_balance arm)B-strkey
SignerKey preAuthTx / hashX / ed25519SignedPayloadT/X/P-strkey
Int128Parts, Uint128Parts, Int256Parts, Uint256Partsdecimal string
AssetCode4trimmed text (trailing zero bytes removed)
AssetCode12trimmed 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…" }); // ok
xdr.AlphaNum4.fromJson({ assetCode: "USD", issuer: "GAAQ…" }); // throws: unknown field assetCode
xdr.AlphaNum4.fromJson({ asset_code: "USD", issuer: "GAAQ…", extra: 1 }); // throws: unknown field extra

The same applies to enum and union case names. Only the snake_case, prefix-stripped spelling works:

xdr.ScValType.fromJson("i32"); // ok
xdr.ScValType.fromJson("scvI32"); // throws: unknown enum name scvI32
xdr.Asset.fromJson({ credit_alphanum4: payload }); // ok
xdr.Asset.fromJson({ assetTypeCreditAlphanum4: payload }); // throws: unknown case

One 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" }); // throws

Method names

  • value.toJson() JSON-encodes. It returns the parsed JSON value (object, array, string, number, boolean, or null). Use JSON.stringify(...) if you want a string.
  • Type.fromJson(json) JSON-decodes. It accepts the same shape toJson produces and throws on malformed structure.
  • value.toJSON() (capital JSON) is the JavaScript-standard hook called by JSON.stringify; it delegates to toJson(), so JSON.stringify(value) === JSON.stringify(value.toJson()). Call toJson() in your own code; the hook exists for implicit serialization (loggers, res.json, snapshots). To substitute a custom encoding under JSON.stringify, use a replacer function (its this[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 as validateXdr (casing now matches fromXdr/toXdr): Uint8Array input, or a string with "hex" | "base64". As with fromXdr, 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, call fromXdr in a try/catch instead (§ 15).

    // Before
    if (xdr.TransactionEnvelope.validateXDR(str, "base64")) { … }
    // After
    if (xdr.TransactionEnvelope.validateXdr(str, "base64")) { … }
  • xdr.scvSortedMap() is gone. The legacy SDK monkey-patched it onto the xdr namespace 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:

package.json
"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 undefined
tx.cond().v2().timeBounds(); // undefined
// Now: null
xdr.PreconditionsV2.fromXdr(bytes).timeBounds; // null

This is a silent change. The shape of the check is what breaks, not the type:

// ⚠ Compiles, never matches any more
if (v2.timeBounds === undefined) { … }
// ⚠ Worse: the guard passes for null, then the access throws
if (v2.timeBounds !== undefined) {
v2.timeBounds.minTime; // TypeError: … of null
}
// ✅ Covers both
if (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 a TypeError (“Cannot read properties of undefined”).
  • Primitive optionals (uint32*, int64*) likewise build, then throw an xdr.XdrError at encode time naming the field.
  • string<N> and byte-typedef optionals (homeDomain, DataValue*) coerce their input in the constructor, so they throw a TypeError right 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]". Any catch block matching on error text needs rewriting.

  • XdrError covers the xdr namespace only. A few SDK entry points decode base64 or hex themselves, before the xdr layer sees the bytes, and surface their decoder’s error instead:

    • new Transaction(envelope), new FeeBumpTransaction(envelope), and tx.addSignature(key, sig) decode base64 with the platform atob, which Node words DOMException: Invalid character and other runtimes word differently.
    • Operation.setOptions’s hex signer keys (sha256Hash, preAuthTx) decode with uint8array-extras, which throws a plain Error reading Invalid Hex character encountered at position 0 — the same wording on every runtime.

    Entry points that hand the string straight to the xdr layer do throw XdrError, including TransactionBuilder.fromXdr and SorobanDataBuilder’s constructor and fromXdr.

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 0x200x7E 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 it

Re-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.MuxedAccount
muxed.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 function

TypeScript 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.

Terminal window
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).