xrpl-go v0.3.0: rippled 3.3.0 support, signature hardening, and network identity

xrpl-go v0.3.0 is out. It’s the largest release the library has had, it tracks rippled 3.3.0, and it contains breaking changes across most packages.

If you’re evaluating Go SDKs for the XRP Ledger, the short version is this: v0.3.0 is where the library stopped trusting its inputs — the server it talks to, the signatures it verifies, the numbers it decodes off the wire. Most of what follows is that principle applied in a dozen places.

There’s a v0.2.0 → v0.3.0 upgrade guide for the mechanical parts. This post covers what’s behind them.

Signature and key handling

The changes we’d most want you to read, whichever SDK you end up choosing:

Malleable signatures are rejected. secp256k1 verification now rejects high-S signatures that don’t meet XRPL’s fully canonical signature requirement. A malleable signature is one an attacker can transform into a second valid signature over the same message — different bytes, different transaction hash. Any system keying off a transaction ID it computed before validation needs the verifier to reject these.

Private scalars are validated. secp256k1 signing rejects zero and out-of-range private scalars rather than silently reducing them modulo the curve order. Reduction turns a malformed key into a working one that signs for an address the caller never intended.

Public keys are validated as curve points. DeriveClassicAddress verifies that compressed secp256k1 public keys encode valid points before hashing, and rejects uncompressed and other unsupported encodings with ErrInvalidPublicKeyFormat instead of hashing any decodable 33-byte value.

Checksum comparison is constant-time. Base58Check checksum and family-seed prefix comparisons in address-codec run through crypto/subtle, so decoding an address or seed doesn’t leak timing information.

Key algorithm detection also now validates the requested key type, hex encoding, prefix, and exact length before selecting Ed25519 or secp256k1, and signing accepts both raw and 00-prefixed secp256k1 private keys.

The client verifies who it’s talking to

Both clients discover network identity from server_info before any identity-dependent operation, and cache it.

The public NetworkID field is gone, replaced by a mutex-guarded NetworkIdentity() accessor returning (*uint32, string) — network ID and build version. A nil network ID means discovery hasn’t completed yet.

The important part is the failure mode. Autofill and unsigned signing now fail closed when identity discovery fails, rather than proceeding against an unknown network. On WebSocket, Connect requests server_info before starting the background reader, and a failure there fails the connection and closes the socket. Reconnects re-verify identity and reject a changed network ID before publishing the new socket. Concurrent callers share one in-flight discovery, including its failures.

Two practical notes. A self-hosted rippled that doesn’t configure a network ID omits server_info.network_id entirely — rippled only sends it when configured, and the example config stanza ships disabled — so this isn’t just an old-server concern. An omitted value resolves to rippled’s default of 0. And if you’d rather not discover at all, WithNetworkIdentity(networkID, buildVersion) supplies trusted deployment values and bypasses it. For fully offline signing, use wallet.Sign.

NetworkID autofill policy now matches the protocol rules exactly: omitted and rejected for IDs 0–1024, and for IDs above 1024 on rippled before 1.11.0; required and exact for IDs above 1024 on 1.11.0 and later. The same rules apply to Batch inner transactions.

Transaction simulation (XLS-69)

simulate dry runs are now available on both clients, in JSON and binary forms.

You can submit either a validated JSON transaction or an opaque hex blob. JSON requests support server-autofilled NetworkID values and validate supplied ones against the client’s target network. They permit a non-empty SigningPubKey and unsigned Signers or BatchSigners entries, but reject non-empty TxnSignature values — a simulation shouldn’t carry a real signature. Blob requests check hex syntax and delegate the rest to the server.

For anything that needs to show a user the effect of a transaction before they authorise it, this replaces a lot of guesswork.

Batch signing rebuilt on BatchV1_1

EncodeForSigningBatch now produces the BatchV1_1 signing payload from xrpld 3.3.0. The obsolete Batch payload is no longer supported, and existing Batch signature fragments must be recreated.

The new payload binds the outer account and effective sequence, with optional batchAccount binding the Batch signer and signerAccount binding a nested multisigner (valid only alongside batchAccount). SignMultiBatch and CombineBatchSigners implement this end to end: signatures bind the outer account and sequence-or-ticket, inner Delegate and Counterparty authorizers are supported, and fragments signed over different outer fields are rejected. CombineBatchSigners sorts by outer signer account and keeps the first fragment per duplicate account — note that it does not merge nested Signers arrays.

Batch.Validate enforces the XLS-56 limit of 2–8 inner transactions via ErrBatchRawTransactionsCount. Inner transactions preserve the wire-required empty SigningPubKey, and signed Batch blob submission rejects a malformed inner transaction even when the outer signature is valid.

MPT rebuilt on the rippled 3.3.0 model

This one deserves a straight explanation, because it reverses a decision we shipped two releases ago.

v0.1.18 and v0.2.0 exposed Dynamic MPT through a MutableFlags model, where set bits marked what remained changeable. rippled 3.3.0 settled on the inverse: an ImmutableFlags model where set bits mark what can no longer change. We’ve replaced ours to match — MutableFlags and the LsmfMPT*/mutable constants are gone, replaced by types.ImmutableFlags, LsifMPT*, and the TifMPT* constants and setters.

Alongside that, MPTokenIssuanceSet capability enablement moved into transaction Flags: TfMPTSetCanLock, TfMPTSetRequireAuth, TfMPTSetCanEscrow, TfMPTSetCanTrade, TfMPTSetCanTransfer, TfMPTSetCanClawback, and TfMPTSetCanHoldConfidentialBalance. A Holder-only transaction is now rejected as a no-op; it must carry a lock or unlock flag.

MPT amounts are now quoted base-10 strings on the wire rather than uint64, with a new types.MPTAmount type and a 1..2^63-1 range on MaximumAmount. UInt64 serialization became field-aware to support this: MPT amount fields use quoted decimal, everything else stays hexadecimal. Clawback gained MPT amount and Holder support, and there’s a LsfMPTAMM flag for AMM-owned holdings plus confidential-balance capability support.

If you built against Dynamic MPT in v0.2.0, this is the section to budget time for.

Reliable submission is ledger-driven

ErrTransactionNotFound is removed. An exact txnNotFound response is now treated as a pending state rather than a failure — the transaction may simply not have propagated yet. Submission resolves on validation, ledger expiry, repeated transport failure, or context cancellation, and nothing else.

Each polling round verifies the latest validated ledger, performs a final lookup, and reports expiry only when that lookup is still inconclusive after the ledger passes LastLedgerSequence. Expiry errors retain the preliminary engine result. WithMaxRetries now bounds consecutive incomplete rounds caused by query or transport failure — a complete round resets the counter, and successful pending rounds don’t consume the budget.

New SubmitTxAndWaitContext and SubmitTxBlobAndWaitContext variants thread caller cancellation through preparation, submission, and finality monitoring.

Submit preflight also tightened: a complete single-sign or multisign structure is required, including an explicit empty top-level SigningPubKey for multisigned transactions. Partial signing fields return ErrInvalidSignedTransaction.

Fees are exact

Fee configuration moved off float32 throughout — WithFeeCushion is now float64, WithMaxFeeXRP is a decimal string, and DefaultMaxFeeXRP follows. Fee calculation uses shared exact rational arithmetic covering fractional base fees and load factors, rippled-compatible integer EscrowFinish fulfillment scaling, and final whole-drop half-up rounding.

Server response types were corrected to match what rippled actually sends: server_state reports base fee and reserves as integer drops, so those are uint64 now, with pointers where a missing value must be distinguishable from an explicit zero. ClosedLedger.BaseFeeXRP and the Clio equivalents became *float64 for the same reason. Load factor fields became float64 to accept fractional values, and a missing load_factor defaults to the protocol value 1 rather than 0.

There’s also a new exact Drops arithmetic type in xrpl/currency: immutable, non-negative, fraction-preserving, with drops and XRP constructors, comparison, rounding, and formatting. It accepts non-canonical decimal input up to 1024 bytes but does not enforce MaxNativeDrops — validate the protocol limit before encoding. DropsPerXrp is now the exact untyped DropsPerXRP (a source-breaking rename), and types.MaxDrops moved to currency.MaxNativeDrops.

X-address autofill

Both clients autofill X-addresses across Account, Destination, Authorize, Unauthorize, Owner, RegularKey, Delegate, NFTokenMinter, Subject, Issuer, and Holder, in outer and Batch inner transactions.

Embedded Account and Destination tags — including tag 0 — populate the matching tag field. A conflicting explicit tag returns ErrMismatchedTag (now a sentinel; replace errors.As struct checks with errors.Is). A tagged X-address in a field with no tag counterpart returns ErrAccountIDTagNotAllowed rather than silently discarding the tag. Issued-currency amounts accept tagless X-address issuers and reject tagged ones.

Also worth knowing

  • Authorized RPC transport: HTTPS is now required for every HTTP client, credentials are redacted from returned diagnostics, header names are matched case-insensitively, and HTTPS-to-HTTP redirect downgrades are rejected before your CheckRedirect callback runs — so it never observes an Authorization header pointed at a plaintext target.
  • WebSocket request semantics: pending requests return ErrDisconnected immediately on connection loss and are never replayed after reconnect. Request timeouts cover both write and response wait. Automatic reconnects don’t replay subscriptions — you must resubscribe. Connect no longer replaces an active connection, returning ErrAlreadyConnected.
  • server_definitions: typed support for full, legacy, and hash-only protocol definitions, with hash-only responses required to match the request hash.
  • ledger_entry: expanded typed selector support with distinct JSON (node) and binary (node_binary) responses; EntryRequest.Validate now requires exactly one top-level selector.
  • Quality encoding: EncodeQuality normalizes to a 16-digit mantissa and accepts exponents from -96 to 80, rejecting extremes v0.2.0 allowed such as 1e-85. DecodeQuality positions the decimal point correctly below 1 and returns ErrInvalidQuality instead of panicking on short input.
  • Pseudo-transactions: EnableAmendment, SetFee, and UNLModify hash correctly without account signatures, with a raw field-value override handling UNLModify’s empty Account in a rippled-compatible way.
  • DelegateSet: a present empty Permissions list deletes a Delegate object; Vault, Loan, and Batch permissions are rejected per XLS-75.
  • Dependencies: minimum Go is now 1.25.12 for upstream standard-library security fixes, and golang.org/x/crypto moves to v0.54.0 for upstream SSH fixes.
  • Testing: an xrpld localnet integration runner and CI workflow, with live-ledger coverage for X-address autofill and NetworkID policy.

Upgrading

go get github.com/Peersyst/xrpl-go@v0.3.0

The module path is unchanged even though the repository now lives at github.com/XRPLF/xrpl-go. Go 1.25.12 or later is required.

Work through the upgrade guide rather than upgrading and chasing compile errors — several changes compile fine and fail at runtime. The ones to check first:

  1. Batch signature fragments must be recreated under BatchV1_1. Old keyed BatchSignable literals that omit Account or Sequence still compile but can’t produce a valid payload.
  2. NetworkID field access is now NetworkIdentity(), and it can return nil before discovery completes.
  3. Dynamic MPT MutableFlags is replaced by ImmutableFlags with inverted semantics.
  4. MPT amount fields are quoted base-10 strings, not uint64. Various OwnerNode fields became hex strings.
  5. ErrTransactionNotFound is gone; txnNotFound is a pending state.
  6. Fee configuration moved from float32 to float64 and decimal strings.
  7. ErrMismatchedTag is a sentinel, not a struct — use errors.Is.

Deprecated aliases are kept where they can be: ErrMissingSignature aliases ErrNonSignedTransaction, ErrSignerDataIsEmpty and ErrBatchRawTransactionsEmpty still match their replacements under errors.Is.

What came before

v0.2.0 (5 June) was the correctness release: Amount serialization stopped accepting float64, malformed input across address-codec, keypairs, and binary-codec began returning errors instead of panicking, the WebSocket client gained per-request-ID dispatch and proper reconnect backoff, and local Validate() was brought in line with rippled for AccountSet, Escrow, NFToken, and SignerListSet. It also fixed an off-by-one in the variable-length prefix encoder that corrupted the following field at exactly 12,480 bytes.

v0.1.19 preserved DeletedNode.PreviousFields in transaction metadata, so balance changes decode for deleted ledger entries.

The pattern across all three is the same: the integration test sweep added in v0.1.18 found the v0.2.0 bugs, and the localnet runner added in v0.3.0 is what surfaced most of the identity and submission fixes above.


One thing we’d rather say plainly than bury: the signing code in this repository has not been independently audited. Treat local signing as security-sensitive, test against Testnet or Devnet first, and never log or commit seeds and private keys.

Issues and PRs: github.com/XRPLF/xrpl-go · Docs: xrplf.github.io/xrpl-go · Reference: pkg.go.dev