SPB Git

Repository Public

xrpl.ts 11.4 KB 200 lines typescript

1// XRP Ledger family: native XRP Payment only (no issued currencies, no trust lines, no paths, no partial payments).2// Pure module: the transaction is a fixed set of fields in the ledger's canonical binary format3// (https://xrpl.org/docs/references/protocol/binary-format), proven byte-for-byte against xrpl.js / ripple-binary-codec4// in packages/core/test/xrpl.test.ts. The official codec is a test-only dependency: the encoding below is ~40 lines and5// keeps the signer's runtime on @noble / @scure only.6import { secp256k1 } from '@noble/curves/secp256k1.js';7import { ripemd160 } from '@noble/hashes/legacy.js';8import { sha256, sha512 } from '@noble/hashes/sha2.js';9import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js';10import { base58xrp } from '@scure/base';11import { baseUnits } from '../amounts.ts';12import type { FamilyPlugin, KeyHandle, Signed } from '../families.ts';13import type { Intent } from '../intent.ts';14import type { NetworkDef } from '../networks.ts';1516/** Hash prefixes: "STX\0" for single-signing, "TXN\0" for the transaction id. */17const PREFIX_SIGN = Uint8Array.of(0x53, 0x54, 0x58, 0x00), PREFIX_TXID = Uint8Array.of(0x54, 0x58, 0x4e, 0x00);18/** tfFullyCanonicalSig: only the low-S form of the signature is valid (always enforced on mainnet today; set explicitly anyway). */19export const XRPL_TF_FULLY_CANONICAL_SIG = 0x8000_0000;20/** The ledger holds 10^17 drops in total; nothing larger can be a valid amount. */21const MAX_DROPS = 100_000_000_000_000_000n;22/** Absolute fee bound of this builder (1 XRP), independent of the policy ceiling: the whole Fee field is destroyed. */23export const XRPL_MAX_FEE_DROPS = 1_000_000n;24const X_KEYS = ['fee', 'lastLedgerSequence', 'sequence'];25const UINT32 = /^(0|[1-9]\d{0,9})$/;2627const cat = (...parts: (Uint8Array | number[])[]): Uint8Array => {28  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));29  let o = 0;30  for (const p of parts) { out.set(p, o); o += p.length; }31  return out;32};33const hexUp = (b: Uint8Array) => bytesToHex(b).toUpperCase();34export const sha512Half = (b: Uint8Array): Uint8Array => sha512(b).slice(0, 32);3536function uint32(s: string | undefined, what: string): number {37  if (s === undefined || !UINT32.test(s)) throw new Error(`invalid ${what}`);38  const v = Number(s);39  if (v > 0xffff_ffff) throw new Error(`${what} out of range`);40  return v;41}42const u32 = (v: number) => { const b = new Uint8Array(4); new DataView(b.buffer).setUint32(0, v); return b; };43/** Native amount: bit 63 = 0 (XRP), bit 62 = 1 (positive), 62 bits of drops. */44function drops(v: bigint): Uint8Array {45  if (v < 0n || v > MAX_DROPS) throw new Error('amount out of range');46  const b = new Uint8Array(8);47  new DataView(b.buffer).setBigUint64(0, 0x4000_0000_0000_0000n | v);48  return b;49}50/** Length-prefixed blob; every blob in a Payment (key, signature) is at most 72 bytes, i.e. a one-byte length. */51function vl(b: Uint8Array): Uint8Array {52  if (b.length > 192) throw new Error('blob too long');53  return cat([b.length], b);54}5556/** 20-byte account id of a classic address. X-addresses, other networks' encodings and non-canonical strings throw. */57export function xrplAccountId(addr: string): Uint8Array {58  if (!/^r[1-9A-HJ-NP-Za-km-z]{24,34}$/.test(addr)) {59    if (/^[XT][1-9A-HJ-NP-Za-km-z]{46}$/.test(addr)) throw new Error('X-addresses are not supported: use the classic r… address and enter the destination tag separately');60    throw new Error('invalid XRP Ledger address');61  }62  let raw: Uint8Array;63  try { raw = base58xrp.decode(addr); } catch { throw new Error('invalid XRP Ledger address'); }64  if (raw.length !== 25 || raw[0] !== 0x00) throw new Error('invalid XRP Ledger address');65  const sum = sha256(sha256(raw.subarray(0, 21)));66  for (let k = 0; k < 4; k++) if (raw[21 + k] !== sum[k]) throw new Error('bad XRP Ledger address checksum');67  if (base58xrp.encode(raw) !== addr) throw new Error('non-canonical XRP Ledger address');68  return raw.slice(1, 21);69}70function encodeAccountId(id: Uint8Array): string {71  const body = cat([0x00], id);72  return base58xrp.encode(cat(body, sha256(sha256(body)).subarray(0, 4)));73}74export function xrplAddressFromPublicKey(publicKey: Uint8Array): string {75  if (publicKey.length !== 33 || (publicKey[0] !== 0x02 && publicKey[0] !== 0x03)) throw new Error('expected a 33-byte compressed secp256k1 public key');76  secp256k1.Point.fromBytes(publicKey); // throws when the point is not on the curve77  return encodeAccountId(ripemd160(sha256(publicKey)));78}7980interface Payment { account: Uint8Array; destination: Uint8Array; amount: bigint; fee: bigint; sequence: number; lastLedgerSequence: number; destinationTag: number | undefined }8182/**83 * Canonical serialization: fields ordered by (type code, field code).84 * TransactionType(1,2) Flags(2,2) Sequence(2,4) DestinationTag(2,14) LastLedgerSequence(2,27) Amount(6,1) Fee(6,8)85 * SigningPubKey(7,3) TxnSignature(7,4) Account(8,1) Destination(8,3).86 */87function serialize(p: Payment, signingPubKey: Uint8Array, signature?: Uint8Array): Uint8Array {88  return cat(89    [0x12, 0x00, 0x00], // TransactionType = Payment90    [0x22], u32(XRPL_TF_FULLY_CANONICAL_SIG),91    [0x24], u32(p.sequence),92    p.destinationTag === undefined ? [] : cat([0x2e], u32(p.destinationTag)),93    [0x20, 0x1b], u32(p.lastLedgerSequence),94    [0x61], drops(p.amount),95    [0x68], drops(p.fee),96    [0x73], vl(signingPubKey),97    signature ? cat([0x74], vl(signature)) : [],98    [0x81, 0x14], p.account,99    [0x83, 0x14], p.destination,100  );101}102103/** Everything the signer accepts, checked against the intent alone. */104function paymentOf(i: Intent, net: NetworkDef): Payment {105  if (net.family !== 'xrpl' || i.network !== net.id) throw new Error('intent is not for this XRP Ledger network');106  if (i.chainRef !== net.chainRef) throw new Error('wrong chain: ledger identity mismatch');107  if (i.evm || i.btc || i.sol) throw new Error('foreign chain section in an XRP Ledger intent');108  if (i.asset !== 'native' || i.symbol !== net.native.symbol || i.decimals !== net.native.decimals) throw new Error('only native XRP can be sent on the XRP Ledger');109  const x = i.x;110  if (!x) throw new Error('missing XRP Ledger parameters');111  const keys = Object.keys(x).sort();112  if (keys.length !== X_KEYS.length || keys.some((k, n) => k !== X_KEYS[n])) throw new Error('unknown or missing XRP Ledger parameters');113  const sequence = uint32(x.sequence, 'sequence'), lastLedgerSequence = uint32(x.lastLedgerSequence, 'lastLedgerSequence');114  if (sequence < 1) throw new Error('invalid sequence');115  if (lastLedgerSequence < 1) throw new Error('invalid lastLedgerSequence');116  const fee = baseUnits(x.fee!), amount = baseUnits(i.amount), maxFee = baseUnits(i.maxFee);117  if (fee <= 0n) throw new Error('fee must be positive');118  if (fee > maxFee) throw new Error('fee above the approved maximum');119  if (fee > XRPL_MAX_FEE_DROPS) throw new Error('fee above the absolute XRP Ledger limit of this wallet');120  if (amount <= 0n) throw new Error('amount must be positive');121  if (amount > MAX_DROPS) throw new Error('amount out of range');122  const account = xrplAccountId(i.from), destination = xrplAccountId(i.to);123  if (i.from === i.to) throw new Error('destination equals source');124  if (destination.every((b) => b === 0) || (destination.subarray(0, 19).every((b) => b === 0) && destination[19] === 1)) throw new Error('destination is a black-hole address');125  const destinationTag = i.memo === undefined ? undefined : uint32(i.memo, 'destination tag');126  return { account, destination, amount, fee, sequence, lastLedgerSequence, destinationTag };127}128129function describe(i: Intent, p: Payment): Record<string, string> {130  return { type: 'Payment', from: i.from, to: i.to, amountDrops: p.amount.toString(), feeDrops: p.fee.toString(), sequence: String(p.sequence),131    lastLedgerSequence: String(p.lastLedgerSequence), destinationTag: p.destinationTag === undefined ? '' : String(p.destinationTag),132    flags: '0x' + XRPL_TF_FULLY_CANONICAL_SIG.toString(16) };133}134135/** `aux` may only carry the wallet's public key, and only if it hashes to the intent's source address. */136function auxKey(i: Intent, aux?: Record<string, unknown>): Uint8Array | undefined {137  if (!aux) return undefined;138  const keys = Object.keys(aux);139  if (keys.length === 0) return undefined;140  if (keys.length !== 1 || keys[0] !== 'signingPubKey' || typeof aux.signingPubKey !== 'string' || !/^0[23][0-9a-fA-F]{64}$/.test(aux.signingPubKey)) throw new Error('unknown helper data for an XRP Ledger transfer');141  const pk = hexToBytes(aux.signingPubKey);142  if (xrplAddressFromPublicKey(pk) !== i.from) throw new Error('public key does not match the source address');143  return pk;144}145146/** The exact bytes hashed for signing: "STX\0" + the transaction without TxnSignature. */147export function xrplSigningBytes(i: Intent, net: NetworkDef, publicKey: Uint8Array): Uint8Array {148  const p = paymentOf(i, net);149  if (xrplAddressFromPublicKey(publicKey) !== i.from) throw new Error('public key does not match the source address');150  return cat(PREFIX_SIGN, serialize(p, publicKey));151}152153/** Transaction id of a signed blob (hex): SHA-512Half("TXN\0" + blob), upper-case hex as the ledger prints it. */154export function xrplTxid(signedBlobHex: string): string {155  if (!/^([0-9a-fA-F]{2})+$/.test(signedBlobHex)) throw new Error('invalid transaction blob');156  return hexUp(sha512Half(cat(PREFIX_TXID, hexToBytes(signedBlobHex))));157}158159export const xrplPlugin: FamilyPlugin = {160  family: 'xrpl',161  curve: 'secp256k1',162  path(net) {163    if (net.family !== 'xrpl' || net.coinType === undefined) throw new Error('not an XRP Ledger network');164    return `m/44'/${net.coinType}'/0'/0/0`;165  },166  address(net, publicKey) {167    if (net.family !== 'xrpl') throw new Error('not an XRP Ledger network');168    return xrplAddressFromPublicKey(publicKey);169  },170  normalizeAddress(net, addr) {171    if (net.family !== 'xrpl') throw new Error('not an XRP Ledger network');172    xrplAccountId(addr);173    return addr;174  },175  /**176   * The signing bytes contain SigningPubKey, which this method is not given: `from` (its hash) commits to it instead.177   * With `aux.signingPubKey` (verified against `from`) the result is the exact signing pre-image; without it, the same178   * pre-image with an empty SigningPubKey (the form the ledger's `simulate` accepts). sign() always uses the real key179   * and refuses a key that does not hash to `from`.180   */181  build(i, net, aux) {182    const p = paymentOf(i, net);183    const pk = auxKey(i, aux);184    return { signDocs: [cat(PREFIX_SIGN, serialize(p, pk ?? new Uint8Array(0)))], describe: { ...describe(i, p), signingPubKey: pk ? hexUp(pk) : '' } };185  },186  sign(i: Intent, net: NetworkDef, key: KeyHandle, aux?: Record<string, unknown>): Signed {187    const p = paymentOf(i, net);188    auxKey(i, aux);189    if (key.curve !== 'secp256k1' || !key.signDigest) throw new Error('XRP Ledger signing needs a secp256k1 key');190    if (xrplAddressFromPublicKey(key.publicKey) !== i.from) throw new Error('source address is not controlled by this key');191    const digest = sha512Half(cat(PREFIX_SIGN, serialize(p, key.publicKey)));192    const sig = key.signDigest(digest);193    // never release a signature that the network would not accept: it must verify and be in low-S (canonical) form194    if (!secp256k1.verify(sig.compact, digest, key.publicKey, { prehash: false, lowS: true })) throw new Error('signature self-check failed');195    if (hexUp(secp256k1.Signature.fromBytes(sig.compact, 'compact').toBytes('der')) !== hexUp(sig.der)) throw new Error('signature encoding self-check failed');196    const raw = hexUp(serialize(p, key.publicKey, sig.der));197    return { raw, txid: xrplTxid(raw) };198  },199};200
Files main
Menu

Theme