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"; // trufflehog:ignore

function xrpAddressFromPubkey(compressedPubkey: Uint8Array): string {
  const accountId = ripemd160(sha256(compressedPubkey));
  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);
  const btcEncoded = bs58.encode(full);
  return btcEncoded.split("").map(c => RIPPLE_ALPHABET[BITCOIN_ALPHABET.indexOf(c)]).join("");
}

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

Sign a Message

import { sha256 } from "@noble/hashes/sha2";

async function signXrpMessage(message: string): Promise<string> {
  const wallet = dynamicClient.wallets.userWallets.find(w => w.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 dynamicClient.wallets.waas.signRawMessage(wallet.id, {
    accountAddress: wallet.address,
    message: bytesToHex(digest),
  });

  return signature;
}

Sign a Transaction

Build an XRP Payment transaction, sign with SHA-512/256, DER-encode the signature, and submit:
import { sha512 } from "@noble/hashes/sha2";

async function sendXrpTransfer(
  to: string,
  amount: number,
  xrpAddress: string,
  publicKey: string,
): Promise<string> {
  const wallet = dynamicClient.wallets.userWallets.find(w => w.chain === "EVM")!;
  const pubkeyBytes = hexToBytes(publicKey);
  const drops = BigInt(Math.round(amount * 1_000_000));

  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;

  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);

  const signature = await dynamicClient.wallets.waas.signRawMessage(wallet.id, {
    accountAddress: wallet.address,
    message: bytesToHex(hash),
  });

  // DER-encode the signature with low-S normalization
  const sigRaw = hexToBytes(signature.replace(/^0x/, ""));
  const derSig = sigToDER(bytesToHex(sigRaw.slice(0, 64)));

  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;
}
For the XRP binary serialization helpers (serializePayment, sigToDER, xrpAddressToAccountId, xrpRpc) see the JavaScript XRP guide. The only difference in React Native is the signing call.

Verify a Signature

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(signature.replace(/^0x/, ""));
  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);
    return sig.recoverPublicKey(digest).toHex(true) === publicKey.toLowerCase();
  } catch {
    return false;
  }
}

Check Balance

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();
}