Files
zeekay 57b741cf31 feat(xrp): safe withdrawals — destination-tag plumbing + authoritative withdrawal gate
Makes XRP withdrawal safe to enable (testnet on; mainnet staged-gated) by closing
the fund-loss + gate-bypass vectors a red/blue loop surfaced over three rounds.

DESTINATION TAG (else a tag-less payout to an exchange pool is lost):
- internal/xrpl: ValidateDestination (reject X-addresses + validate r-address);
  parseAccount recover-wrapper (fixes a rippledata base58 panic-DoS reachable in
  the signing goroutine); tag set on the tx BEFORE SigningHash so it is committed
  into the SHA-512Half the MPC signs (unstrippable, proven).
- internal/txassembler: XRPSpec.DestinationTag *uint32 -> PaymentUnsigned.
- cmd/bridge: Swap.DestinationTag, createSwapReq accepts+range-checks it [0,2^32),
  signOneXRP threads it + re-validates the destination (terminal-fail on bad addr).

AUTHORITATIVE WITHDRAWAL GATE (was cosmetic — read only by the SPA list):
- config.go: WithdrawalEnabled/DepositEnabled now enforced server-side. Case-
  INSENSITIVE (network,asset) match + default-DENY for wired non-EVM families
  (BTC/SOL/TON/XRP/DOT) so an asset-case variant ('xrp'), empty asset, or network-
  case variant ('bitcoin_testnet') can't dodge a disabled row into a release pool
  (gate key now aligns with the network-only, case-folded signer routing). EVM
  stays default-on.
- Enforced at CREATE (swapsCreateNative 403), SIGN (signOne skip = kill-switch),
  BROADCAST (broadcastOne holds already-signed in-flight swaps), and the operator
  INJECT path (swapsInjectRawTxNative 403). signOne also gains a defer-recover
  backstop so one swap's panic can't crash the daemon.

Tests: tag carried+committed+on-wire; X-addr/bad-addr/panic-input rejected; tag
range boundaries; gate variant-bypass closed; create/sign/broadcast/inject all
honor the gate; kill-switch holds then resumes. cmd/bridge + xrpl + txassembler
green, full module green, vet clean.

KNOWN FOLLOW-UPS (pre-existing, bridge-wide, NOT XRP-specific; tracked):
- /admin/swaps/:id/{reset,inject-raw-tx} have no auth middleware (bounded by
  64-bit random swap IDs + the now-gated inject). Gate behind operator token/mTLS.
- GET /v1/bridge/swaps has no tenant scoping (IDOR). Both are mainnet-readiness
  items; mainnet XRP withdrawal stays isWithdrawalEnabled:false until addressed +
  a live testnet tag-carrying-payout proof.
2026-06-26 10:14:38 -07:00

470 lines
19 KiB
Go

// Package xrpl is the bridge's XRP Ledger primitives: address derivation
// from a compressed-secp256k1 pubkey, canonical signing-payload assembly
// for a Payment transaction (STX-prefixed SHA-512Half), ECDSA DER
// encoding with low-s normalization, and final signed-tx blob + tx-hash
// computation.
//
// The bridge does NOT hold any XRPL secret material — the MPC cluster
// signs the canonical 32-byte digest produced here and returns (r, s).
// This package then re-encodes the signature in ASN.1 DER (the wire
// format XRPL expects in `TxnSignature`), attaches it to the unsigned
// transaction, and serializes the final binary blob for broadcast.
//
// Why a thin wrapper around rubblelabs/ripple: that library has a
// complete, well-tested binary codec covering every field type XRPL
// has shipped. Re-implementing the codec from scratch would be 1500+
// lines of error-prone serialization for no gain. We use it for codec
// + address-encoding helpers and keep our own signing flow on top.
//
// Scope:
// - Payment transaction only (native XRP-drops Amount, no IOU paths).
// TrustSet / OfferCreate / etc. would be additional PreSign variants
// in this same package using the same MPC entry-point — out of scope
// for the bridge's initial XRP support.
// - secp256k1 signatures only. XRPL also supports ed25519 (key-prefix
// 0xED), but the MPC cluster's threshold-ECDSA primitive only emits
// secp256k1 signatures, so the bridge sticks to ECDSA addresses.
package xrpl
import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
"errors"
"fmt"
"math/big"
rcrypto "github.com/rubblelabs/ripple/crypto"
rippledata "github.com/rubblelabs/ripple/data"
"golang.org/x/crypto/ripemd160"
)
// =============================================================================
// secp256k1 ECDSA — low-s normalization + DER encoding
// =============================================================================
// secp256k1N is the order of the secp256k1 base point. Used to
// normalize signatures to canonical low-s form (XRPL rejects high-s
// signatures as non-canonical per `tfFullyCanonicalSig`).
var secp256k1N, _ = new(big.Int).SetString(
"fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 16)
// secp256k1HalfN is N/2 — the threshold above which s is "high" and
// must be replaced with N - s for canonical encoding.
var secp256k1HalfN = new(big.Int).Rsh(secp256k1N, 1)
// CanonicalizeS replaces s with (N - s) when s > N/2 so the resulting
// signature is in canonical low-s form. Returns the canonical s.
// Idempotent — passing an already-low-s value returns the same value.
func CanonicalizeS(s *big.Int) *big.Int {
if s == nil {
return nil
}
if s.Cmp(secp256k1HalfN) > 0 {
return new(big.Int).Sub(secp256k1N, s)
}
return new(big.Int).Set(s)
}
// EncodeDERSignature encodes (r, s) as an ASN.1 DER ECDSA signature:
//
// 0x30 || len || 0x02 || rLen || r || 0x02 || sLen || s
//
// The r and s integers are encoded as minimal big-endian bytes, with
// a leading 0x00 prepended whenever the high bit would otherwise make
// the value look negative under ASN.1 INTEGER's signed interpretation.
// This is the canonical form `rippled` expects in `TxnSignature`.
//
// s is canonicalized to low-s automatically.
func EncodeDERSignature(r, s *big.Int) ([]byte, error) {
if r == nil || s == nil {
return nil, errors.New("xrpl: EncodeDERSignature: r and s required")
}
if r.Sign() <= 0 || s.Sign() <= 0 {
return nil, fmt.Errorf("xrpl: EncodeDERSignature: r and s must be positive (got r.Sign()=%d s.Sign()=%d)", r.Sign(), s.Sign())
}
if r.Cmp(secp256k1N) >= 0 || s.Cmp(secp256k1N) >= 0 {
return nil, errors.New("xrpl: EncodeDERSignature: r and s must be < N")
}
canonS := CanonicalizeS(s)
rBytes := minimalDERInt(r)
sBytes := minimalDERInt(canonS)
// SEQUENCE: 0x30, len, (INTEGER r), (INTEGER s)
inner := make([]byte, 0, 4+len(rBytes)+len(sBytes))
inner = append(inner, 0x02, byte(len(rBytes)))
inner = append(inner, rBytes...)
inner = append(inner, 0x02, byte(len(sBytes)))
inner = append(inner, sBytes...)
out := make([]byte, 0, 2+len(inner))
out = append(out, 0x30, byte(len(inner)))
out = append(out, inner...)
return out, nil
}
// minimalDERInt converts a big.Int to its ASN.1 DER INTEGER body
// (without the leading 0x02 || length tag). Adds a leading 0x00 when
// the high bit of the first byte is set (DER signed encoding rule).
func minimalDERInt(n *big.Int) []byte {
b := n.Bytes()
if len(b) > 0 && b[0]&0x80 != 0 {
return append([]byte{0x00}, b...)
}
return b
}
// =============================================================================
// Address derivation from compressed secp256k1 pubkey
// =============================================================================
// AddressFromPubKey derives the XRPL r-address (base58check, "r..." prefix)
// from a 33-byte compressed-secp256k1 public key.
//
// accountID = RIPEMD160(SHA256(pubkey))
// address = base58check(0x00 || accountID, xrp_alphabet)
//
// The 0x00 prefix byte and the XRPL-specific base58 alphabet are what
// distinguish r-addresses from Bitcoin addresses (which share the same
// hash function and similar checksum scheme).
//
// The input must be a 33-byte compressed point (starts with 0x02 or
// 0x03 — secp256k1 compressed encoding); a 65-byte uncompressed key
// or any other length is rejected.
func AddressFromPubKey(pubkey []byte) (string, error) {
if len(pubkey) != 33 {
return "", fmt.Errorf("xrpl: AddressFromPubKey: want 33-byte compressed pubkey, got %d", len(pubkey))
}
if pubkey[0] != 0x02 && pubkey[0] != 0x03 {
return "", fmt.Errorf("xrpl: AddressFromPubKey: not a compressed-secp256k1 key (leading byte 0x%02x; want 0x02 or 0x03)", pubkey[0])
}
accountID := sha256ThenRipemd160(pubkey)
h, err := rcrypto.NewAccountId(accountID)
if err != nil {
return "", fmt.Errorf("xrpl: derive r-address: %w", err)
}
return h.String(), nil
}
// AccountIDFromPubKey returns the 20-byte XRPL AccountID — the
// RIPEMD160(SHA256(pubkey)) digest, before base58check encoding. Useful
// when callers want to feed the bytes directly into a transaction's
// Account field without round-tripping through the string form.
func AccountIDFromPubKey(pubkey []byte) ([]byte, error) {
if len(pubkey) != 33 {
return nil, fmt.Errorf("xrpl: AccountIDFromPubKey: want 33-byte compressed pubkey, got %d", len(pubkey))
}
if pubkey[0] != 0x02 && pubkey[0] != 0x03 {
return nil, fmt.Errorf("xrpl: AccountIDFromPubKey: not compressed-secp256k1 (leading byte 0x%02x)", pubkey[0])
}
return sha256ThenRipemd160(pubkey), nil
}
// ValidateDestination checks that addr is a usable XRP Payment
// destination — a well-formed classic r-address — and REJECTS XRPL
// X-addresses.
//
// X-addresses (mainnet "X…", testnet/devnet "T…") pack a destination
// tag + network flag INSIDE the address. The bridge carries the tag in
// a separate Payment field (DestinationTag), so honoring an X-address
// would mean splitting it apart — and a tag-less Payment to the
// embedded AccountID would silently misroute funds at an exchange's
// pooled deposit address (irreversible loss). Rather than ship an
// X-address codec the signing path can't use, we reject at the API
// boundary and tell the caller to supply a classic r-address plus an
// explicit destination tag.
//
// Returns nil for a valid classic r-address; a descriptive error
// otherwise. Call this at swap-create time so a bad destination fails
// fast instead of stalling the swap until its expiry.
func ValidateDestination(addr string) error {
if addr == "" {
return errors.New("xrpl: destination address required")
}
// Classic r-addresses always start with 'r' (AccountID version byte
// 0x00). X-addresses start with 'X' (mainnet) or 'T' (testnet/devnet);
// reject them explicitly with an actionable message. NewAccountFromAddress
// below would also fail on an X-address, but with an opaque
// "bad version" error that doesn't tell the user what to do.
if c := addr[0]; c == 'X' || c == 'T' {
return fmt.Errorf("xrpl: destination %q is an X-address (it embeds a destination tag + network); supply a classic r-address and an explicit destination tag instead", addr)
}
if _, err := parseAccount(addr); err != nil {
return fmt.Errorf("xrpl: invalid destination r-address %q: %w", addr, err)
}
return nil
}
// parseAccount safely parses an XRPL classic address into an Account.
//
// It wraps rippledata.NewAccountFromAddress, which can PANIC ("slice
// bounds out of range [:-1]") on certain malformed inputs: its base58
// decoder slices off a 4-byte checksum WITHOUT bounds-checking the
// decoded length, so a 5+ char string that decodes to fewer than 4
// bytes (e.g. "ppppp") trips a negative slice bound. We recover any
// such panic into a clean error so no untrusted address can crash the
// parse — fail-secure at and behind the API boundary.
func parseAccount(addr string) (acct *rippledata.Account, err error) {
defer func() {
if r := recover(); r != nil {
acct = nil
err = fmt.Errorf("malformed address (decoder fault): %v", r)
}
}()
return rippledata.NewAccountFromAddress(addr)
}
// sha256ThenRipemd160 is the standard "double-hash" XRPL uses for
// account IDs: SHA-256 first, then RIPEMD-160 over the SHA-256 digest.
// Bitcoin uses the same hashing pipeline; the XRPL difference is the
// version-byte prefix (0x00) and the base58 alphabet, both handled
// by rcrypto.NewAccountId / Hash.String().
func sha256ThenRipemd160(b []byte) []byte {
s := sha256.Sum256(b)
h := ripemd160.New()
_, _ = h.Write(s[:])
return h.Sum(nil)
}
// =============================================================================
// Payment transaction — signing payload + finalization
// =============================================================================
// HashPrefixTransactionSign is the 4-byte prefix XRPL prepends to a
// transaction's binary body before SHA-512Half hashing for signing.
// Spelled "STX\0" in ASCII (0x53545800). See `rippled/src/ripple/protocol/HashPrefix.h`.
const HashPrefixTransactionSign uint32 = 0x53545800
// HashPrefixTransactionID is the 4-byte prefix XRPL prepends to the
// FULL signed binary body before SHA-512Half hashing for the tx hash.
// "TXN\0" → 0x54584E00.
const HashPrefixTransactionID uint32 = 0x54584E00
// XRPLBaseFeeDrops is the protocol-floor fee for a Payment transaction
// in drops (10 drops = 1e-5 XRP). Operators set higher fees during
// busy ledgers; this is the floor every Payment must pay.
const XRPLBaseFeeDrops int64 = 10
// XRPLBaseReserveDrops is the minimum XRP an account must hold to
// remain alive on the ledger. Currently 10 XRP (10,000,000 drops).
// Release wallets must hold this MUCH more than the per-payment
// amount to keep their AccountRoot from being deleted.
const XRPLBaseReserveDrops int64 = 10_000_000
// DropsPerXRP is 1e6. One XRP = 1,000,000 drops.
const DropsPerXRP int64 = 1_000_000
// CanonicalSigFlag is the recommended Flags bit for every signed
// transaction. Forces strict low-s + low-r canonicalization on the
// server side and rejects malleable signatures.
const CanonicalSigFlag uint32 = 0x80000000
// PaymentUnsigned is the bridge's intent of a native-XRP Payment
// transaction, in human-friendly form. PreSign converts this into the
// canonical signing payload + binary body the MPC quorum signs.
type PaymentUnsigned struct {
// Account is the sender's r-address (the release-wallet address
// derived from the MPC pubkey).
Account string
// Destination is the recipient r-address.
Destination string
// AmountDrops is the value transferred in drops (1 XRP = 1e6 drops).
// Must be positive and within int64 range — XRPL has no Payment
// larger than 100B XRP.
AmountDrops int64
// FeeDrops is the transaction fee in drops. Pre-XRPL-amendments
// minimum is 10 drops; the broadcaster bumps this on retry if
// the load-factor demands it.
FeeDrops int64
// Sequence is the next sequence number the Account expects.
// Source it from rippled's account_info before calling PreSign.
Sequence uint32
// LastLedgerSequence bounds tx validity to within ~30 ledgers
// (≈2 minutes). When omitted the tx never expires — operators
// should always set this. Zero ⇒ unset.
LastLedgerSequence uint32
// SigningPubKey is the 33-byte compressed-secp256k1 pubkey of the
// signing wallet. XRPL embeds this in the wire payload so any
// observer can verify the signature against the account.
SigningPubKey []byte
// Flags is the optional Flags field (e.g. tfFullyCanonicalSig).
Flags uint32
// DestinationTag is the optional 32-bit memo XRP exchanges + custodial
// services use to route funds to a sub-account. nil ⇒ unset.
DestinationTag *uint32
}
// SignCtx is the opaque context PreSign returns and Finalize requires.
// It carries the original tx + signing pubkey, so Finalize can produce
// a bit-identical wire body once the MPC signs the digest.
//
// Callers should treat this as a single round-trip handle and not
// inspect its fields.
type SignCtx struct {
Payment *rippledata.Payment
SigningPubKey []byte
}
// PreSign builds a Payment transaction's signing payload and returns
// both the 32-byte digest the MPC quorum must sign (the canonical
// SHA-512Half of `0x53545800 || serialized_body`) and a SignCtx the
// caller will hand back to Finalize once the signature is in.
//
// Mechanics:
// 1. Construct a rubblelabs Payment struct from the bridge's
// PaymentUnsigned (Account, Destination, AmountDrops, Fee,
// Sequence, etc.). The library handles every field-id, type-byte,
// and length-prefix detail of the XRPL binary codec.
// 2. Populate SigningPubKey on the transaction. XRPL requires
// the pubkey to be on the wire so verifiers can recompute the
// digest and validate the signature.
// 3. Call rippledata.SigningHash(tx) — emits (hash, msg) where
// msg is the binary body WITHOUT the prefix and hash is the
// SHA-512Half of `prefix || msg`. The bridge returns hash as
// the "signing payload" the MPC signs.
//
// Errors are returned for malformed inputs (bad addresses, negative
// amounts, missing pubkey).
func PreSign(u PaymentUnsigned) ([]byte, *SignCtx, error) {
if err := validatePayment(u); err != nil {
return nil, nil, err
}
from, err := parseAccount(u.Account)
if err != nil {
return nil, nil, fmt.Errorf("xrpl: parse Account %q: %w", u.Account, err)
}
to, err := parseAccount(u.Destination)
if err != nil {
return nil, nil, fmt.Errorf("xrpl: parse Destination %q: %w", u.Destination, err)
}
amount, err := rippledata.NewAmount(fmt.Sprintf("%d", u.AmountDrops))
if err != nil {
return nil, nil, fmt.Errorf("xrpl: build Amount(%d drops): %w", u.AmountDrops, err)
}
fee, err := rippledata.NewValue(fmt.Sprintf("%d", u.FeeDrops), true)
if err != nil {
return nil, nil, fmt.Errorf("xrpl: build Fee(%d drops): %w", u.FeeDrops, err)
}
tx := &rippledata.Payment{
TxBase: rippledata.TxBase{
TransactionType: rippledata.PAYMENT,
Account: *from,
Sequence: u.Sequence,
Fee: *fee,
},
Destination: *to,
Amount: *amount,
}
if u.LastLedgerSequence != 0 {
v := u.LastLedgerSequence
tx.LastLedgerSequence = &v
}
if u.Flags != 0 {
f := rippledata.TransactionFlag(u.Flags)
tx.Flags = &f
}
if u.DestinationTag != nil {
v := *u.DestinationTag
tx.DestinationTag = &v
}
// Embed the signing pubkey on the wire. XRPL verifiers reconstruct
// the digest from the binary body (which includes this field) and
// check the signature against this pubkey.
tx.SigningPubKey = new(rippledata.PublicKey)
copy(tx.SigningPubKey[:], u.SigningPubKey)
// SigningHash returns (hash, msg, err) where hash = SHA-512Half of
// (prefix || msg). That's exactly the message the MPC must sign.
hash, _, err := rippledata.SigningHash(tx)
if err != nil {
return nil, nil, fmt.Errorf("xrpl: compute SigningHash: %w", err)
}
pubCopy := make([]byte, len(u.SigningPubKey))
copy(pubCopy, u.SigningPubKey)
return hash.Bytes(), &SignCtx{Payment: tx, SigningPubKey: pubCopy}, nil
}
// Finalize attaches a DER-encoded ECDSA signature to the unsigned
// transaction carried in ctx and returns:
// - signedBlob: the full wire-format binary body (no STX prefix)
// that goes into `submit`'s tx_blob param as hex.
// - txHash: the canonical XRPL transaction hash (SHA-512Half of
// `0x54584E00 || signedBlob`), uppercase hex per XRPL
// convention. This is what the rippled `submit` reply echoes back
// in `result.tx_json.hash` and what users see in explorers.
//
// signatureDER must be the DER-encoded (r, s) signature — produced by
// EncodeDERSignature(r, s) after the MPC returns. Low-s normalization
// is the caller's responsibility (EncodeDERSignature does it).
func Finalize(signatureDER []byte, ctx *SignCtx) ([]byte, string, error) {
if ctx == nil || ctx.Payment == nil {
return nil, "", errors.New("xrpl: Finalize: nil SignCtx")
}
if len(signatureDER) == 0 {
return nil, "", errors.New("xrpl: Finalize: empty DER signature")
}
// Attach the DER signature to TxnSignature.
sig := rippledata.VariableLength(append([]byte(nil), signatureDER...))
ctx.Payment.TxnSignature = &sig
// Raw(tx) returns:
// hash = SHA-512Half(HP_TRANSACTION_ID || encoded_tx_body)
// raw = the encoded tx body itself (no prefix)
// — exactly what we need.
hash, raw, err := rippledata.Raw(ctx.Payment)
if err != nil {
return nil, "", fmt.Errorf("xrpl: Finalize: encode signed tx: %w", err)
}
return raw, hex.EncodeToString(hash.Bytes()), nil
}
// =============================================================================
// Helpers
// =============================================================================
// SHA512Half computes the canonical XRPL hash: SHA-512 of the prefix
// + data, truncated to the first 32 bytes. Exposed so tests can
// independently verify PreSign's digest computation matches the spec.
func SHA512Half(prefix uint32, data []byte) []byte {
h := sha512.New()
var p [4]byte
p[0] = byte(prefix >> 24)
p[1] = byte(prefix >> 16)
p[2] = byte(prefix >> 8)
p[3] = byte(prefix)
_, _ = h.Write(p[:])
_, _ = h.Write(data)
sum := h.Sum(nil)
out := make([]byte, 32)
copy(out, sum[:32])
return out
}
func validatePayment(u PaymentUnsigned) error {
if u.Account == "" {
return errors.New("xrpl: PaymentUnsigned.Account required")
}
if u.Destination == "" {
return errors.New("xrpl: PaymentUnsigned.Destination required")
}
if u.AmountDrops <= 0 {
return fmt.Errorf("xrpl: PaymentUnsigned.AmountDrops must be > 0 (got %d)", u.AmountDrops)
}
if u.FeeDrops <= 0 {
return fmt.Errorf("xrpl: PaymentUnsigned.FeeDrops must be > 0 (got %d)", u.FeeDrops)
}
if len(u.SigningPubKey) != 33 {
return fmt.Errorf("xrpl: PaymentUnsigned.SigningPubKey must be 33 bytes (compressed secp256k1); got %d", len(u.SigningPubKey))
}
if u.SigningPubKey[0] != 0x02 && u.SigningPubKey[0] != 0x03 {
return fmt.Errorf("xrpl: PaymentUnsigned.SigningPubKey not compressed (leading byte 0x%02x)", u.SigningPubKey[0])
}
return nil
}