SPB Git

Repository Public

xrpl.test.ts 17.1 KB 240 lines typescript

1// XRP Ledger plugin, proven against the official SDK (xrpl.js 5.3.0 = ripple-binary-codec + ripple-address-codec + ripple-keypairs).2import { describe, expect, it } from 'vitest';3import fc from 'fast-check';4import { secp256k1 } from '@noble/curves/secp256k1.js';5import { classicAddressToXAddress, decode, encode, encodeForSigning, hashes, isValidClassicAddress, verifySignature, Wallet } from 'xrpl';6import {7  canonicalBytes, DEFAULT_POLICY, intentHash, isValidAddress, NETWORKS, normalizeAddress, pluginFor, sha512Half, toHex,8  xrplAddressFromPublicKey, xrplPlugin, xrplSigningBytes, xrplTxid, type Intent, type KeyHandle,9} from '../src/index.ts';1011// Public BIP-39 test vector mnemonic — holds no funds of ours, never used outside tests.12const TEST_MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';13/** Address of that mnemonic at m/44'/144'/0'/0/0 as published by the usual BIP-44 tools (and present on the main ledger). */14const VECTOR_ADDRESS = 'rHsMGQEkVNJmpGWs8XUBoTBiAAbwxZN5v3';15const net = NETWORKS.xrpl;16const wallet = Wallet.fromMnemonic(TEST_MNEMONIC, { derivationPath: xrplPlugin.path(net) });17const pub = Uint8Array.from(Buffer.from(wallet.publicKey, 'hex'));18const priv = Uint8Array.from(Buffer.from(wallet.privateKey, 'hex')).slice(-32); // xrpl.js prefixes secp256k1 keys with 0019/** Same shape as the signer's key handle (services/isolated-signer/src/keys.ts). */20const key: KeyHandle = { curve: 'secp256k1', publicKey: pub, signDigest: (digest) => {21  const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true, format: 'recovered' });22  const compact = sig.slice(1);23  return { compact, recovery: sig[0]!, der: secp256k1.Signature.fromBytes(compact, 'compact').toBytes('der') };24} };25const DEST = 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe', DEST2 = 'rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn';26const now = 1_800_000_000;27const base: Intent = { v: 1, proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a11', keyId: 'spw-0123456789abcdef', createdAt: now, expiresAt: now + 45, policyVersion: 3,28  network: 'xrpl', chainRef: net.chainRef, asset: 'native', symbol: 'XRP', decimals: 6, amount: '2500000', from: VECTOR_ADDRESS, to: DEST, maxFee: '12',29  x: { sequence: '210', lastLedgerSequence: '108249099', fee: '12' } };30const withTag: Intent = { ...base, memo: '4294967295' };31const HEX = (b: Uint8Array) => toHex(b).toUpperCase();3233/** The same payment as xrpl.js sees it. */34function sdkTx(i: Intent, signingPubKey = wallet.publicKey) {35  return { TransactionType: 'Payment' as const, Account: i.from, Destination: i.to, Amount: i.amount, Fee: i.x!.fee!, Flags: 0x80000000,36    Sequence: Number(i.x!.sequence), LastLedgerSequence: Number(i.x!.lastLedgerSequence), SigningPubKey: signingPubKey,37    ...(i.memo !== undefined ? { DestinationTag: Number(i.memo) } : {}) };38}3940describe('xrpl: registration', () => {41  it('is registered as a plugin family with the pinned network definition and policy', () => {42    expect(pluginFor(net)).toBe(xrplPlugin);43    expect(net).toMatchObject({ id: 'xrpl', family: 'xrpl', name: 'XRP Ledger', coinType: 144, memo: 'uint32', activation: '1' });44    expect(net.native).toEqual({ id: 'native', symbol: 'XRP', name: 'XRP', decimals: 6 });45    expect(net.rpc.length).toBe(2);46    expect(new Set(net.rpc.map((u) => new URL(u).host.split('.').slice(-2).join('.'))).size).toBe(2);47    expect(DEFAULT_POLICY.perTx['xrpl:XRP']).toBe('47000000');48    expect(DEFAULT_POLICY.daily['xrpl:XRP']).toBe('94000000');49    expect(DEFAULT_POLICY.maxFee.xrpl).toBe('100000');50  });51});5253describe('xrpl: keys and addresses', () => {54  it("derives m/44'/144'/0'/0/0 of the public test mnemonic to the published address (and to what xrpl.js derives)", () => {55    expect(xrplPlugin.curve).toBe('secp256k1');56    expect(xrplPlugin.path(net)).toBe("m/44'/144'/0'/0/0");57    expect(xrplPlugin.address(net, pub)).toBe(VECTOR_ADDRESS);58    expect(wallet.classicAddress).toBe(VECTOR_ADDRESS);59    expect(xrplAddressFromPublicKey(pub)).toBe(VECTOR_ADDRESS);60  });61  it('matches xrpl.js for random keys (property)', () => {62    fc.assert(fc.property(fc.uint8Array({ minLength: 32, maxLength: 32 }), (seed) => {63      fc.pre(secp256k1.utils.isValidSecretKey(seed));64      const pk = secp256k1.getPublicKey(seed, true);65      const w = new Wallet(HEX(pk), '00' + HEX(seed));66      return xrplPlugin.address(net, pk) === w.classicAddress && isValidClassicAddress(xrplPlugin.address(net, pk));67    }), { numRuns: 40 });68  });69  it('accepts canonical classic addresses only', () => {70    expect(normalizeAddress('xrpl', DEST)).toBe(DEST);71    expect(normalizeAddress('xrpl', ` ${DEST} `)).toBe(DEST); // the shared entry point trims72    expect(xrplPlugin.normalizeAddress(net, VECTOR_ADDRESS)).toBe(VECTOR_ADDRESS);73    expect(() => xrplPlugin.normalizeAddress(net, ` ${DEST}`)).toThrow();74    const x = classicAddressToXAddress(DEST, 12345, false), xNoTag = classicAddressToXAddress(DEST, false, false);75    expect(() => normalizeAddress('xrpl', x)).toThrow(/X-addresses are not supported/);76    expect(() => normalizeAddress('xrpl', xNoTag)).toThrow(/X-addresses are not supported/);77    expect(() => normalizeAddress('xrpl', classicAddressToXAddress(DEST, false, true))).toThrow(/X-addresses/); // testnet form78    expect(() => normalizeAddress('xrpl', DEST.slice(0, -1) + (DEST.endsWith('e') ? 'f' : 'e'))).toThrow(/checksum|invalid/);79    expect(() => normalizeAddress('xrpl', DEST.toLowerCase())).toThrow();80    expect(() => normalizeAddress('xrpl', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq')).toThrow();81    expect(() => normalizeAddress('xrpl', '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2')).toThrow(); // Bitcoin base58 alphabet / version82    expect(() => normalizeAddress('xrpl', '0x70997970C51812dc3A010C7d01b50e0d17dc79C8')).toThrow();83    expect(() => normalizeAddress('xrpl', 'sEdTM1uX8pu2do5XvTnutH6HsouMaM2')).toThrow(); // a seed is not an address84    expect(() => normalizeAddress('xrpl', '')).toThrow();85    expect(isValidAddress('xrpl', DEST2)).toBe(true);86    expect(() => normalizeAddress('bitcoin', DEST)).toThrow();87  });88});8990describe('xrpl: transaction bytes against the official codec', () => {91  it('signing bytes = STX prefix + ripple-binary-codec encodeForSigning (with and without destination tag)', () => {92    for (const i of [base, withTag, { ...base, memo: '0' }]) {93      const mine = xrplSigningBytes(i, net, pub);94      expect(HEX(mine)).toBe(encodeForSigning(sdkTx(i)));95      expect(HEX(mine).startsWith('53545800')).toBe(true);96      // build() with the (verified) public key returns exactly those bytes97      expect(HEX(xrplPlugin.build(i, net, { signingPubKey: wallet.publicKey }).signDocs[0]!)).toBe(HEX(mine));98      // build() without the key: same transaction, empty SigningPubKey99      expect(HEX(xrplPlugin.build(i, net).signDocs[0]!)).toBe(encodeForSigning(sdkTx(i, '')));100    }101  });102  it('the signed blob is byte-identical to xrpl.js Wallet.sign, decodes to the intent fields, and the signature verifies', () => {103    for (const i of [base, withTag, { ...base, memo: '0' }, { ...base, amount: '99999999999999999', maxFee: '100000', x: { sequence: '4294967295', lastLedgerSequence: '4294967295', fee: '100000' } }]) {104      const mine = xrplPlugin.sign(i, net, key);105      const sdk = wallet.sign(sdkTx(i));106      expect(mine.raw).toBe(sdk.tx_blob);107      expect(mine.txid).toBe(sdk.hash);108      expect(mine.txid).toBe(hashes.hashSignedTx(mine.raw));109      expect(xrplTxid(mine.raw.toLowerCase())).toBe(mine.txid);110      expect(encode(decode(mine.raw) as never)).toBe(mine.raw); // canonical for the official codec111      const d = decode(mine.raw) as Record<string, unknown>;112      const { TxnSignature, ...rest } = d;113      expect(rest).toEqual(sdkTx(i));114      expect(Object.keys(d).sort()).toEqual(['Account', 'Amount', 'Destination', ...(i.memo !== undefined ? ['DestinationTag'] : []), 'Fee', 'Flags', 'LastLedgerSequence', 'Sequence', 'SigningPubKey', 'TransactionType', 'TxnSignature'].sort());115      expect(verifySignature(mine.raw)).toBe(true); // official SDK116      const digest = sha512Half(xrplSigningBytes(i, net, pub));117      expect(secp256k1.verify(Uint8Array.from(Buffer.from(String(TxnSignature), 'hex')), digest, pub, { prehash: false, format: 'der', lowS: true })).toBe(true); // noble118    }119  });120  it('matches the official codec for arbitrary payments (property)', () => {121    const u32 = fc.integer({ min: 1, max: 0xffffffff });122    fc.assert(fc.property(fc.bigInt({ min: 1n, max: 100_000_000_000_000_000n }), fc.bigInt({ min: 1n, max: 1_000_000n }), u32, u32,123      fc.option(fc.integer({ min: 0, max: 0xffffffff }), { nil: undefined }), fc.uint8Array({ minLength: 20, maxLength: 20 }), (amount, fee, seq, lls, tag, destId) => {124        fc.pre(destId.some((b, k) => (k < 19 ? b !== 0 : b > 1)));125        const to = xrplAddressFromPublicKeyHash(destId);126        const i: Intent = { ...base, amount: amount.toString(), to, maxFee: fee.toString(), x: { sequence: String(seq), lastLedgerSequence: String(lls), fee: fee.toString() }, ...(tag !== undefined ? { memo: String(tag) } : {}) };127        const s = xrplPlugin.sign(i, net, key);128        return HEX(xrplSigningBytes(i, net, pub)) === encodeForSigning(sdkTx(i)) && s.raw === wallet.sign(sdkTx(i)).tx_blob && verifySignature(s.raw);129      }), { numRuns: 60 });130  });131  it('is deterministic and the intent hash covers the chain parameters and the tag', () => {132    expect(xrplPlugin.sign(base, net, key)).toEqual(xrplPlugin.sign(base, net, key));133    const h = toHex(intentHash(base));134    for (const m of [{ memo: '1' }, { x: { ...base.x!, sequence: '211' } }, { x: { ...base.x!, lastLedgerSequence: '108249100' } }, { x: { ...base.x!, fee: '11' } }, { amount: '2500001' }, { to: DEST2 }])135      expect(toHex(intentHash({ ...base, ...m }))).not.toBe(h);136    expect(canonicalBytes(base).length).toBeGreaterThan(0);137    expect(() => canonicalBytes({ ...base, x: undefined })).toThrow(/chain section/);138  });139});140141/** classic address of a raw account id, through the official address codec */142function xrplAddressFromPublicKeyHash(id: Uint8Array): string {143  const blob = '1200002280000000240000000161400000000000000168400000000000000A73008114' + HEX(id) + '8314' + HEX(id);144  return String((decode(blob) as { Account: string }).Account);145}146147describe('xrpl: fail closed', () => {148  const bad = (m: Partial<Intent>, aux?: Record<string, unknown>) => () => xrplPlugin.build({ ...base, ...m }, net, aux);149  it('refuses another chain, another family, foreign sections', () => {150    expect(bad({ chainRef: NETWORKS.bitcoin.chainRef })).toThrow(/wrong chain/);151    expect(bad({ chainRef: net.chainRef.toLowerCase() })).toThrow(/wrong chain/);152    expect(bad({ network: 'solana' })).toThrow(/not for this/);153    expect(() => xrplPlugin.build(base, NETWORKS.bitcoin)).toThrow(/not for this/);154    expect(() => xrplPlugin.build(base, { ...net, chainRef: 'AB'.repeat(32) })).toThrow(/wrong chain/);155    expect(bad({ evm: { nonce: 0, gasLimit: '21000', maxFeePerGas: '1', maxPriorityFeePerGas: '1' } })).toThrow(/foreign/);156    expect(bad({ btc: { inputs: [{ txid: 'aa'.repeat(32), vout: 0, value: '1' }], change: '0', fee: '1' } })).toThrow(/foreign/);157  });158  it('refuses anything but native XRP', () => {159    expect(bad({ asset: 'rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De' })).toThrow(/only native XRP/); // an issuer account (issued currency)160    expect(bad({ asset: 'USD' })).toThrow(/only native XRP/);161    expect(bad({ symbol: 'RLUSD' })).toThrow(/only native XRP/);162    expect(bad({ decimals: 8 })).toThrow(/only native XRP/);163  });164  it('refuses invalid, non-normalized, X- and black-hole addresses and a self-send', () => {165    expect(bad({ to: classicAddressToXAddress(DEST, 7, false) })).toThrow(/X-addresses/);166    expect(bad({ to: ` ${DEST}` })).toThrow(/invalid/);167    expect(bad({ to: DEST.slice(0, -1) + 'z' })).toThrow();168    expect(bad({ from: DEST.slice(0, -2) + 'zz' })).toThrow();169    expect(bad({ to: VECTOR_ADDRESS })).toThrow(/destination equals source/);170    expect(bad({ to: 'rrrrrrrrrrrrrrrrrrrrrhoLvTp' })).toThrow(/black-hole/); // ACCOUNT_ZERO171    expect(bad({ to: 'rrrrrrrrrrrrrrrrrrrrBZbvji' })).toThrow(/black-hole/); // ACCOUNT_ONE172    expect(bad({ to: '0x70997970C51812dc3A010C7d01b50e0d17dc79C8' })).toThrow();173  });174  it('refuses bad amounts and fees', () => {175    expect(bad({ amount: '0' })).toThrow(/amount must be positive/);176    expect(bad({ amount: '100000000000000001' })).toThrow(/out of range/);177    expect(bad({ amount: '-1' })).toThrow();178    expect(bad({ amount: '1.5' })).toThrow();179    expect(bad({ maxFee: '11' })).toThrow(/fee above the approved maximum/);180    expect(bad({ x: { ...base.x!, fee: '0' } })).toThrow(/fee must be positive/);181    expect(bad({ x: { ...base.x!, fee: '012' } })).toThrow();182    expect(bad({ maxFee: '1000001', x: { ...base.x!, fee: '1000001' } })).toThrow(/absolute/);183  });184  it('refuses unknown, missing or malformed chain parameters and tags', () => {185    expect(bad({ x: undefined })).toThrow(/missing/);186    expect(bad({ x: { ...base.x!, flags: '131072' } })).toThrow(/unknown or missing/); // e.g. an attempt at tfPartialPayment187    expect(bad({ x: { ...base.x!, sendMax: '1' } })).toThrow(/unknown or missing/);188    expect(bad({ x: { sequence: '210', fee: '12' } })).toThrow(/unknown or missing/);189    expect(bad({ x: { sequence: '210', lastLedgerSequence: '108249099', Fee: '12' } })).toThrow(/unknown or missing/);190    for (const v of ['0', '-1', '4294967296', '1e3', '0x10', '', ' 5', '05']) {191      expect(bad({ x: { ...base.x!, sequence: v } }), `sequence ${v}`).toThrow();192      expect(bad({ x: { ...base.x!, lastLedgerSequence: v } }), `lastLedgerSequence ${v}`).toThrow();193    }194    for (const v of ['-1', '4294967296', 'abc', '', '01', '1.0', ' 1', '12345678901']) expect(bad({ memo: v }), `tag ${v}`).toThrow(/destination tag/);195  });196  it('accepts helper data only when it is the matching public key', () => {197    expect(bad({}, { signingPubKey: wallet.publicKey })).not.toThrow();198    expect(bad({}, {})).not.toThrow();199    expect(bad({}, { prevTx: {} })).toThrow(/unknown helper/);200    expect(bad({}, { signingPubKey: wallet.publicKey, extra: 1 })).toThrow(/unknown helper/);201    expect(bad({}, { signingPubKey: 'zz' })).toThrow(/unknown helper/);202    expect(bad({}, { signingPubKey: HEX(secp256k1.getPublicKey(new Uint8Array(32).fill(7), true)) })).toThrow(/does not match/);203  });204  it('sign() refuses a key that is not the source account, a wrong curve, and a bad signature from the key module', () => {205    const otherPriv = new Uint8Array(32).fill(7);206    const other: KeyHandle = { ...key, publicKey: secp256k1.getPublicKey(otherPriv, true) };207    expect(() => xrplPlugin.sign(base, net, other)).toThrow(/not controlled/);208    expect(() => xrplPlugin.sign(base, net, { curve: 'ed25519', publicKey: pub.slice(1), signMessage: () => new Uint8Array(64) })).toThrow(/secp256k1/);209    expect(() => xrplPlugin.sign(base, net, { ...key, signDigest: undefined })).toThrow(/secp256k1/);210    const wrongKeySigner: KeyHandle = { ...key, signDigest: (d) => { // signs with another private key211      const sig = secp256k1.sign(d, otherPriv, { prehash: false, lowS: true, format: 'recovered' }); const compact = sig.slice(1);212      return { compact, recovery: sig[0]!, der: secp256k1.Signature.fromBytes(compact, 'compact').toBytes('der') }; } };213    expect(() => xrplPlugin.sign(base, net, wrongKeySigner)).toThrow(/self-check/);214    const highS: KeyHandle = { ...key, signDigest: (d) => { // non-canonical (high-S) signature215      const s = secp256k1.Signature.fromBytes(key.signDigest!(d).compact, 'compact');216      const flipped = new secp256k1.Signature(s.r, secp256k1.Point.CURVE().n - s.s);217      return { compact: flipped.toBytes('compact'), recovery: 0, der: flipped.toBytes('der') }; } };218    expect(() => xrplPlugin.sign(base, net, highS)).toThrow(/self-check/);219    expect(() => xrplPlugin.sign({ ...base, amount: '0' }, net, key)).toThrow(/positive/);220    expect(() => xrplPlugin.sign({ ...base, maxFee: '1' }, net, key)).toThrow(/approved maximum/);221  });222  it('changing any intent field changes the signed bytes or throws', () => {223    const ref = xrplPlugin.sign(base, net, key);224    const changes: Partial<Intent>[] = [{ amount: '2500001' }, { to: DEST2 }, { memo: '7' }, { x: { ...base.x!, sequence: '211' } },225      { x: { ...base.x!, lastLedgerSequence: '108249100' } }, { maxFee: '13', x: { ...base.x!, fee: '13' } }];226    const seen = new Set([ref.raw]);227    for (const c of changes) {228      const r = xrplPlugin.sign({ ...base, ...c }, net, key);229      expect(seen.has(r.raw), JSON.stringify(c)).toBe(false);230      expect(r.txid).not.toBe(ref.txid);231      seen.add(r.raw);232    }233    for (const c of [{ from: DEST2 }, { chainRef: '1' }, { asset: 'USD' }, { symbol: 'XLM' }, { decimals: 7 }, { network: 'bitcoin' }, { maxFee: '11' }] as Partial<Intent>[])234      expect(() => xrplPlugin.sign({ ...base, ...c }, net, key), JSON.stringify(c)).toThrow();235    // fields that are not part of the ledger transaction are still bound by the approval (intent hash)236    for (const c of [{ proposalId: '6f1c1f0e-8f5f-4b1e-9c56-0d6b5f0f1a12' }, { keyId: 'spw-fedcba9876543210' }, { expiresAt: now + 46 }, { createdAt: now + 1 }, { policyVersion: 4 }, { maxFee: '13' }] as Partial<Intent>[])237      expect(toHex(intentHash({ ...base, ...c }))).not.toBe(toHex(intentHash(base)));238  });239});240
Files main
Menu

Theme