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.| Property | Value |
|---|---|
| Curve | secp256k1 |
| Root Wallet | EVM |
| Address Format | base58check with Ripple alphabet (r...) |
| Hashing | SHA-256 + RIPEMD-160 (address), SHA-512/256 (signing) |
| Serialization | XRP binary format |
| Smallest Unit | Drops (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();
}