Skip to main content

Overview

XRP uses the secp256k1 elliptic curve — the same as Ethereum. The address is derived from the compressed public key using SHA-256 + RIPEMD-160, then base58check-encoded with the Ripple alphabet (which differs from the Bitcoin alphabet).
PropertyValue
Curvesecp256k1
Root WalletEVM
Address Formatbase58check with Ripple alphabet (r prefix)
HashingSHA-256 + RIPEMD-160 (address), SHA-512 half (tx)
SerializationXRP binary (canonical field ordering)
Smallest Unitdrop (1 XRP = 1,000,000 drops)

Dependencies

Add to pubspec.yaml:
dependencies:
  pointycastle: ^3.9.1   # SHA-256, RIPEMD-160, SHA-512
  bs58: ^1.0.2           # Base58 encoding
  http: ^1.2.2           # HTTP client

Derive Address

XRP address derivation requires the compressed secp256k1 public key. You recover it once per session from the EVM wallet:
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';
import 'package:bs58/bs58.dart';

const _bitcoinAlphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
const _rippleAlphabet  = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz';

String _bitcoinToRippleBase58(String b58) {
  return String.fromCharCodes(b58.codeUnits.map(
    (ch) => _rippleAlphabet.codeUnitAt(
      _bitcoinAlphabet.indexOf(String.fromCharCode(ch)),
    ),
  ));
}

String xrpAddressFromPubkey(Uint8List compressedPubkey) {
  final sha = Digest('SHA-256');
  final sha256Hash = sha.process(compressedPubkey);
  final accountId = Digest('RIPEMD-160').process(sha256Hash);

  final versioned = Uint8List(21);
  versioned[0] = 0x00; // version byte
  versioned.setAll(1, accountId);

  final checksum = sha.process(sha.process(versioned)).sublist(0, 4);
  final full = Uint8List.fromList([...versioned, ...checksum]);
  return _bitcoinToRippleBase58(base58.encode(full));
}

Future<String> deriveXrpAddress({
  required Future<String> Function(String) signEip191,
  required String evmAddress,
}) async {
  final compressedPubkey = await recoverEvmPublicKey(
    recoveryMessage: 'PUBKEY_RECOVERY',
    signEip191: signEip191,
    evmAddress: evmAddress,
  );
  return xrpAddressFromPubkey(compressedPubkey);
}
Cache the compressedPubkey — key recovery only needs to happen once per session.

Sign a Message

XRP message signing uses SHA-256 of the raw message bytes:
import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';

/// Compute the XRP message digest: SHA-256(messageBytes)
String xrpMessageDigest(String message) {
  final sha = Digest('SHA-256');
  return bytesToHex(sha.process(Uint8List.fromList(utf8.encode(message))));
}

Future<String> signXrpMessage(String message) async {
  final evmWallet = DynamicSDK.instance.wallets.userWallets
      .firstWhere((w) => w.chain == 'EVM');

  final hexDigest = xrpMessageDigest(message);

  final signature = await DynamicSDK.instance.wallets.signRawMessage(
    walletId: evmWallet.id,
    accountAddress: evmWallet.address,
    message: hexDigest,
  );

  return signature;
}

Sign a Transaction

XRP transactions use a binary serialization format with canonical field ordering. The signing hash is the first 32 bytes of SHA-512 applied to a prefix (0x53545800) concatenated with the serialized transaction. The signature must be DER-encoded with low-S normalization:
import 'dart:convert';
import 'dart:typed_data';
import 'package:pointycastle/pointycastle.dart';
import 'package:http/http.dart' as http;

const _xrpRpcUrl = 'https://s.altnet.rippletest.net:51234'; // testnet

/// DER-encode an ECDSA signature from compact r+s hex (128 chars), normalizing S to low-S.
Uint8List _sigToDER(String rsHex) {
  final r = hexToBytes(rsHex.substring(0, 64));
  final sNorm = normalizeLowS(BigInt.parse(rsHex.substring(64), radix: 16));
  final s = hexToBytes(sNorm.toRadixString(16).padLeft(64, '0'));

  Uint8List intBytes(Uint8List v) {
    var start = 0;
    while (start < v.length - 1 && v[start] == 0) start++;
    final trimmed = v.sublist(start);
    return (trimmed[0] & 0x80) != 0
        ? Uint8List.fromList([0, ...trimmed])
        : trimmed;
  }

  final rDer = intBytes(r);
  final sDer = intBytes(s);
  final contentLen = 2 + rDer.length + 2 + sDer.length;
  final result = Uint8List(2 + contentLen);
  result[0] = 0x30; // SEQUENCE
  result[1] = contentLen;
  result[2] = 0x02; result[3] = rDer.length;
  result.setAll(4, rDer);
  result[4 + rDer.length] = 0x02;
  result[5 + rDer.length] = sDer.length;
  result.setAll(6 + rDer.length, sDer);
  return result;
}

Future<String> sendXrpTransfer({
  required String to,
  required double amount,
  required String xrpAddress,
  required Uint8List compressedPubkey,
  required SignWithEvm signRaw, // callback: (Uint8List digest) → Future<String>
}) async {
  final accountId = _xrpAddressToAccountId(xrpAddress);
  final destinationId = _xrpAddressToAccountId(to);
  final drops = BigInt.from((amount * 1000000).round());

  // Fetch account info and current fee in parallel
  final results = await Future.wait([
    _xrpRpc('account_info', [{'account': xrpAddress, 'ledger_index': 'current'}]),
    _xrpRpc('fee'),
  ]);

  final sequence = results[0]['account_data']['Sequence'] as int;
  final openFee = int.parse(results[1]['drops']?['open_ledger_fee'] ?? '12');
  final fee = BigInt.from(openFee > 12 ? openFee : 12);
  final currentLedger = (results[1]['ledger_current_index'] ?? 0) as int;
  final lastLedger = currentLedger + 75;

  // Serialize the unsigned transaction
  final serialized = _serializePayment(
    account: accountId,
    destination: destinationId,
    amount: drops,
    fee: fee,
    sequence: sequence,
    lastLedgerSequence: lastLedger,
    signingPubKey: compressedPubkey,
  );

  // Signing hash: SHA-512 first half of (0x53545800 + serialized)
  final hashPrefix = Uint8List.fromList([0x53, 0x54, 0x58, 0x00]);
  final hash = Digest('SHA-512')
      .process(Uint8List.fromList([...hashPrefix, ...serialized]))
      .sublist(0, 32);

  final signature = await signRaw(hash);

  final sigRaw = hexToBytes(strip0x(signature));
  final rsHex = bytesToHex(sigRaw.sublist(0, 64));
  final derSig = _sigToDER(rsHex);

  // Build signed transaction and submit
  final signedSerialized = _serializePayment(
    account: accountId,
    destination: destinationId,
    amount: drops,
    fee: fee,
    sequence: sequence,
    lastLedgerSequence: lastLedger,
    signingPubKey: compressedPubkey,
    txnSignature: derSig,
  );

  final result = await _xrpRpc('submit', [
    {'tx_blob': bytesToHex(signedSerialized).toUpperCase()}
  ]);

  final code = (result['engine_result'] ?? '') as String;
  if (!code.startsWith('tes') && !code.startsWith('ter')) {
    throw Exception('XRP submit failed [$code]: ${result['engine_result_message']}');
  }

  return result['tx_json']?['hash'] as String? ?? bytesToHex(
    Digest('SHA-512').process(
      Uint8List.fromList([0x54, 0x58, 0x4e, 0x00, ...signedSerialized]),
    ).sublist(0, 32),
  ).toUpperCase();
}

Future<Map<String, dynamic>> _xrpRpc(String method, [List<dynamic>? params]) async {
  final res = await http.post(
    Uri.parse(_xrpRpcUrl),
    headers: {'Content-Type': 'application/json'},
    body: json.encode({'method': method, 'params': params ?? []}),
  );
  final data = json.decode(res.body) as Map<String, dynamic>;
  if (data['result']?['error'] != null) {
    throw Exception('XRP RPC: ${data['result']['error_message']}');
  }
  return data['result'] as Map<String, dynamic>;
}
For the full XRP binary serialization helpers (_serializePayment, _xrpAddressToAccountId, etc.), see flutter-multichain-demo/lib/chains/ripple.dart.

Check Balance

import 'dart:convert';
import 'package:http/http.dart' as http;

Future<String> getXrpBalance(String address) async {
  try {
    final result = await _xrpRpc('account_info', [
      {'account': address, 'ledger_index': 'validated'}
    ]);
    final drops = BigInt.parse(result['account_data']['Balance'] as String);
    return (drops.toDouble() / 1000000).toString();
  } catch (e) {
    if (e.toString().contains('actNotFound')) return '0';
    rethrow;
  }
}