Repository Public
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