Skip to main content

Overview

XRP uses the secp256k1 elliptic curve — the same as Ethereum. You recover the compressed public key from the EVM wallet, then compute RIPEMD-160(SHA-256(pubkey)) and encode with XRP’s custom base58 alphabet. Transactions use XRP’s binary serialization format and require DER-encoded signatures with low-S normalization.
PropertyValue
Curvesecp256k1
Root WalletEVM
Address Formatbase58check with Ripple alphabet (r...)
HashingSHA-256 + RIPEMD-160 (address), SHA-512/256 (signing)
SerializationXRP binary format
Smallest UnitDrops (1 XRP = 10^6 drops)

Dependencies

npm install @noble/curves @noble/hashes bs58

Derive Address

Recover the secp256k1 compressed public key, compute the account ID, and encode with XRP’s base58 alphabet:
import { sha256 } from "@noble/hashes/sha2";
import { ripemd160 } from "@noble/hashes/legacy";
import bs58 from "bs58";

const BITCOIN_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const RIPPLE_ALPHABET = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz";

function bitcoinToRippleBase58(bitcoinB58: string): string {
  let result = "";
  for (const ch of bitcoinB58) {
    result += RIPPLE_ALPHABET[BITCOIN_ALPHABET.indexOf(ch)];
  }
  return result;
}

function xrpAddressFromPubkey(compressedPubkey: Uint8Array): string {
  const sha256Hash = sha256(compressedPubkey);
  const accountId = ripemd160(sha256Hash); // 20 bytes

  // base58check: version(1) + payload(20) + checksum(4)
  const versioned = new Uint8Array(21);
  versioned[0] = 0x00;
  versioned.set(accountId, 1);
  const checksum = sha256(sha256(versioned)).slice(0, 4);
  const full = concatBytes(versioned, checksum);

  return bitcoinToRippleBase58(bs58.encode(full));
}

async function deriveXrpAddress(
  evmWallet: WalletAccount,
  dynamicClient: DynamicClient,
): Promise<{ address: string; publicKey: string }> {
  const compressedPubkey = await recoverEvmPublicKey(
    "XRP_PUBKEY_RECOVERY",
    evmWallet,
    dynamicClient,
  );
  return {
    address: xrpAddressFromPubkey(compressedPubkey),
    publicKey: bytesToHex(compressedPubkey),
  };
}

Sign a Message

Sign using a SHA-256 digest of the XRP message prefix via the EVM WaaS provider:
import { sha256 } from "@noble/hashes/sha2";
import { createWaasProvider } from "@dynamic-labs-sdk/client/waas/core";

async function signXrpMessage(
  message: string,
  evmWallet: WalletAccount,
  dynamicClient: DynamicClient,
): Promise<string> {
  const provider = createWaasProvider({
    sdkClient: dynamicClient,
    chain: "EVM",
  });

  const messageBytes = new TextEncoder().encode(message);
  const prefix = new TextEncoder().encode("\x19XRP Signed Message:\n");
  const lengthStr = new TextEncoder().encode(String(messageBytes.length));
  const digest = sha256(concatBytes(prefix, lengthStr, messageBytes));

  const { signature } = await provider.signRawMessage({
    message: bytesToHex(digest),
    walletAccount: evmWallet,
  });

  return signature;
}

Verify a Signature

Recover the public key from the signature and compare to the stored key:
import { secp256k1 } from "@noble/curves/secp256k1";
import { sha256 } from "@noble/hashes/sha2";

function verifyXrpSignature(
  message: string,
  signature: string,
  publicKey: string,
): boolean {
  const messageBytes = new TextEncoder().encode(message);
  const prefix = new TextEncoder().encode("\x19XRP Signed Message:\n");
  const lengthStr = new TextEncoder().encode(String(messageBytes.length));
  const digest = sha256(concatBytes(prefix, lengthStr, messageBytes));

  const sigBytes = hexToBytes(strip0x(signature));
  const rsHex = bytesToHex(sigBytes.slice(0, 64));
  let v = sigBytes[64];
  if (v >= 27) v -= 27;

  try {
    const sig = secp256k1.Signature.fromHex(rsHex).addRecoveryBit(v);
    const recovered = sig.recoverPublicKey(digest);
    return recovered.toHex(true) === publicKey.toLowerCase();
  } catch {
    return false;
  }
}

Check Balance

Query the XRP balance via JSON-RPC:
async function getXrpBalance(address: string): Promise<string> {
  const res = await fetch("https://s.altnet.rippletest.net:51234", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      method: "account_info",
      params: [{ account: address, ledger_index: "validated" }],
    }),
  });

  const data = await res.json();
  if (data.result?.error) return "0";

  const drops = BigInt(data.result.account_data.Balance);
  return (Number(drops) / 1_000_000).toString();
}

Send a Transfer

Build a Payment transaction, serialize using XRP binary format, sign with DER encoding, and submit.

DER Signature Encoding

XRP requires DER-encoded signatures with low-S normalization:
const SECP256K1_N = BigInt(
  "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141",
);
const SECP256K1_HALF_N = SECP256K1_N / BigInt(2);

function normalizeLowS(s: bigint): bigint {
  return s > SECP256K1_HALF_N ? SECP256K1_N - s : s;
}

function sigToDER(rsHex: string): Uint8Array {
  const r = hexToBytes(rsHex.slice(0, 64));
  const sBigInt = normalizeLowS(BigInt("0x" + rsHex.slice(64)));
  const s = bigintToBytes32(sBigInt);

  function intBytes(v: Uint8Array): Uint8Array {
    let start = 0;
    while (start < v.length - 1 && v[start] === 0) start++;
    const trimmed = v.slice(start);
    if (trimmed[0] & 0x80) {
      const padded = new Uint8Array(trimmed.length + 1);
      padded.set(trimmed, 1);
      return padded;
    }
    return trimmed;
  }

  const rDer = intBytes(r);
  const sDer = intBytes(s);
  const contentLen = 2 + rDer.length + 2 + sDer.length;
  const result = new Uint8Array(2 + contentLen);
  result[0] = 0x30; // SEQUENCE
  result[1] = contentLen;
  result[2] = 0x02; // INTEGER
  result[3] = rDer.length;
  result.set(rDer, 4);
  result[4 + rDer.length] = 0x02;
  result[5 + rDer.length] = sDer.length;
  result.set(sDer, 6 + rDer.length);
  return result;
}

XRP Binary Serialization

function encodeFieldId(typeCode: number, fieldCode: number): Uint8Array {
  if (typeCode < 16 && fieldCode < 16) return new Uint8Array([(typeCode << 4) | fieldCode]);
  if (typeCode < 16 && fieldCode >= 16) return new Uint8Array([(typeCode << 4), fieldCode]);
  if (typeCode >= 16 && fieldCode < 16) return new Uint8Array([fieldCode, typeCode]);
  return new Uint8Array([0, typeCode, fieldCode]);
}

function encodeXrpAmount(drops: bigint): Uint8Array {
  let value = BigInt("0x4000000000000000") | drops;
  const bytes = new Uint8Array(8);
  for (let i = 7; i >= 0; i--) {
    bytes[i] = Number(value & BigInt(0xff));
    value >>= BigInt(8);
  }
  return bytes;
}

Full Transfer Implementation

import { sha512 } from "@noble/hashes/sha2";
import { createWaasProvider } from "@dynamic-labs-sdk/client/waas/core";

async function sendXrpTransfer(
  to: string,
  amount: number,
  xrpAddress: string,
  publicKey: string,
  evmWallet: WalletAccount,
  dynamicClient: DynamicClient,
): Promise<string> {
  const provider = createWaasProvider({
    sdkClient: dynamicClient,
    chain: "EVM",
  });

  const pubkeyBytes = hexToBytes(publicKey);
  const drops = BigInt(Math.round(amount * 1_000_000));

  // Fetch account info and fee
  const [accountInfo, feeInfo] = await Promise.all([
    xrpRpc("account_info", [{ account: xrpAddress, ledger_index: "current" }]),
    xrpRpc("fee", []),
  ]);

  const sequence = accountInfo.account_data.Sequence;
  const fee = BigInt(Math.max(parseInt(feeInfo.drops?.open_ledger_fee || "12"), 12));
  const lastLedger = (feeInfo.ledger_current_index || 0) + 75;

  // Serialize the payment transaction (without signature)
  const serialized = serializePayment({
    account: xrpAddressToAccountId(xrpAddress),
    destination: xrpAddressToAccountId(to),
    amount: drops,
    fee,
    sequence,
    lastLedgerSequence: lastLedger,
    signingPubKey: pubkeyBytes,
  });

  // Signing hash: SHA-512 first half of (0x53545800 + serialized)
  const HASH_PREFIX = new Uint8Array([0x53, 0x54, 0x58, 0x00]);
  const hash = sha512(concatBytes(HASH_PREFIX, serialized)).slice(0, 32);

  // Sign the hash
  const { signature } = await provider.signRawMessage({
    message: bytesToHex(hash),
    walletAccount: evmWallet,
  });

  // DER-encode the signature
  const sigRaw = hexToBytes(strip0x(signature));
  const derSig = sigToDER(bytesToHex(sigRaw.slice(0, 64)));

  // Build signed transaction and submit
  const signedSerialized = serializePayment({
    account: xrpAddressToAccountId(xrpAddress),
    destination: xrpAddressToAccountId(to),
    amount: drops,
    fee,
    sequence,
    lastLedgerSequence: lastLedger,
    signingPubKey: pubkeyBytes,
    txnSignature: derSig,
  });

  const txBlob = bytesToHex(signedSerialized).toUpperCase();
  const result = await xrpRpc("submit", [{ tx_blob: txBlob }]);

  if (!result.engine_result?.startsWith("tes") && !result.engine_result?.startsWith("ter")) {
    throw new Error(`XRP submit failed: ${result.engine_result_message}`);
  }

  return result.tx_json?.hash || bytesToHex(
    sha512(concatBytes(new Uint8Array([0x54, 0x58, 0x4e, 0x00]), signedSerialized)).slice(0, 32),
  ).toUpperCase();
}