UNPKG

@nextrope/xrpl

Version:

A TypeScript/JavaScript API for interacting with the XRP Ledger in Node.js and the browser

514 lines (482 loc) 19.3 kB
import { HDKey } from '@scure/bip32' import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39' import { wordlist } from '@scure/bip39/wordlists/english' import { bytesToHex } from '@xrplf/isomorphic/utils' import BigNumber from 'bignumber.js' import { classicAddressToXAddress, isValidXAddress, xAddressToClassicAddress, encodeSeed, } from 'ripple-address-codec' import { encodeForSigning, encodeForMultisigning, encode, } from 'ripple-binary-codec' import { deriveAddress, deriveKeypair, generateSeed, sign, } from 'ripple-keypairs' import ECDSA from '../ECDSA' import { ValidationError } from '../errors' import { Transaction, validate } from '../models/transactions' import { GlobalFlags } from '../models/transactions/common' import { hasFlag } from '../models/utils' import { ensureClassicAddress } from '../sugar/utils' import { omitBy } from '../utils/collections' import { hashSignedTx } from '../utils/hashes/hashLedger' import { rfc1751MnemonicToKey } from './rfc1751' import { verifySignature } from './signer' const DEFAULT_ALGORITHM: ECDSA = ECDSA.ed25519 const DEFAULT_DERIVATION_PATH = "m/44'/144'/0'/0/0" type ValidHDKey = HDKey & { privateKey: Uint8Array publicKey: Uint8Array } function validateKey(node: HDKey): asserts node is ValidHDKey { if (!(node.privateKey instanceof Uint8Array)) { throw new ValidationError('Unable to derive privateKey from mnemonic input') } if (!(node.publicKey instanceof Uint8Array)) { throw new ValidationError('Unable to derive publicKey from mnemonic input') } } /** * A utility for deriving a wallet composed of a keypair (publicKey/privateKey). * A wallet can be derived from either a seed, mnemonic, or entropy (array of random numbers). * It provides functionality to sign/verify transactions offline. * * @example * ```typescript * * // Derive a wallet from a base58 encoded seed. * const seedWallet = Wallet.fromSeed('ssZkdwURFMBXenJPbrpE14b6noJSu') * console.log(seedWallet) * // Wallet { * // publicKey: '02FE9932A9C4AA2AC9F0ED0F2B89302DE7C2C95F91D782DA3CF06E64E1C1216449', * // privateKey: '00445D0A16DD05EFAF6D5AF45E6B8A6DE4170D93C0627021A0B8E705786CBCCFF7', * // classicAddress: 'rG88FVLjvYiQaGftSa1cKuE2qNx7aK5ivo', * // seed: 'ssZkdwURFMBXenJPbrpE14b6noJSu' * // }. * * // Sign a JSON Transaction * const signed = seedWallet.signTransaction({ * TransactionType: 'Payment', * Account: 'rG88FVLjvYiQaGftSa1cKuE2qNx7aK5ivo' * ........... * }). * * console.log(signed) * // '1200007321......B01BE1DFF3'. * console.log(decode(signed)) * // { * // TransactionType: 'Payment', * // SigningPubKey: '02FE9932A9C4AA2AC9F0ED0F2B89302DE7C2C95F91D782DA3CF06E64E1C1216449', * // TxnSignature: '3045022100AAD......5B631ABD21171B61B07D304', * // Account: 'rG88FVLjvYiQaGftSa1cKuE2qNx7aK5ivo' * // ........... * // } * ``` * * @category Signing */ export class Wallet { public readonly publicKey: string public readonly privateKey: string public readonly classicAddress: string public readonly seed?: string /** * Creates a new Wallet. * * @param publicKey - The public key for the account. * @param privateKey - The private key used for signing transactions for the account. * @param opts - (Optional) Options to initialize a Wallet. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @param opts.seed - The seed used to derive the account keys. */ public constructor( publicKey: string, privateKey: string, opts: { masterAddress?: string seed?: string } = {}, ) { this.publicKey = publicKey this.privateKey = privateKey this.classicAddress = opts.masterAddress ? ensureClassicAddress(opts.masterAddress) : deriveAddress(publicKey) this.seed = opts.seed } /** * Alias for wallet.classicAddress. * * @returns The wallet's classic address. */ public get address(): string { return this.classicAddress } /** * `generate()` creates a new random Wallet. In order to make this a valid account on ledger, you must * Send XRP to it. On test networks that can be done with "faucets" which send XRP to any account which asks * For it. You can call `client.fundWallet()` in order to generate credentials and fund the account on test networks. * * @example * ```ts * const { Wallet } = require('xrpl') * const wallet = Wallet.generate() * ``` * * @param algorithm - The digital signature algorithm to generate an address for. * @returns A new Wallet derived from a generated seed. * * @throws ValidationError when signing algorithm isn't valid */ public static generate(algorithm: ECDSA = DEFAULT_ALGORITHM): Wallet { if (!Object.values(ECDSA).includes(algorithm)) { throw new ValidationError('Invalid cryptographic signing algorithm') } const seed = generateSeed({ algorithm }) return Wallet.fromSeed(seed, { algorithm }) } /** * Derives a wallet from a seed. * * @param seed - A string used to generate a keypair (publicKey/privateKey) to derive a wallet. * @param opts - (Optional) Options to derive a Wallet. * @param opts.algorithm - The digital signature algorithm to generate an address for. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @returns A Wallet derived from a seed. */ public static fromSeed( seed: string, opts: { masterAddress?: string; algorithm?: ECDSA } = {}, ): Wallet { return Wallet.deriveWallet(seed, { algorithm: opts.algorithm, masterAddress: opts.masterAddress, }) } /** * Derives a wallet from a secret (AKA a seed). * * @param secret - A string used to generate a keypair (publicKey/privateKey) to derive a wallet. * @param opts - (Optional) Options to derive a Wallet. * @param opts.algorithm - The digital signature algorithm to generate an address for. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @returns A Wallet derived from a secret (AKA a seed). */ // eslint-disable-next-line @typescript-eslint/member-ordering -- Member is used as a function here public static fromSecret = Wallet.fromSeed /** * Derives a wallet from an entropy (array of random numbers). * * @param entropy - An array of random numbers to generate a seed used to derive a wallet. * @param opts - (Optional) Options to derive a Wallet. * @param opts.algorithm - The digital signature algorithm to generate an address for. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @returns A Wallet derived from an entropy. */ public static fromEntropy( entropy: Uint8Array | number[], opts: { masterAddress?: string; algorithm?: ECDSA } = {}, ): Wallet { const algorithm = opts.algorithm ?? DEFAULT_ALGORITHM const options = { entropy: Uint8Array.from(entropy), algorithm, } const seed = generateSeed(options) return Wallet.deriveWallet(seed, { algorithm, masterAddress: opts.masterAddress, }) } /** * Derives a wallet from a bip39 or RFC1751 mnemonic (Defaults to bip39). * * @deprecated since version 2.6.1. * Will be deleted in version 3.0.0. * This representation is currently deprecated in rippled. * You should use another method to represent your keys such as a seed or public/private keypair. * * @param mnemonic - A string consisting of words (whitespace delimited) used to derive a wallet. * @param opts - (Optional) Options to derive a Wallet. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @param opts.derivationPath - The path to derive a keypair (publicKey/privateKey). Only used for bip39 conversions. * @param opts.mnemonicEncoding - If set to 'rfc1751', this interprets the mnemonic as a rippled RFC1751 mnemonic like * `wallet_propose` generates in rippled. Otherwise the function defaults to bip39 decoding. * @param opts.algorithm - Only used if opts.mnemonicEncoding is 'rfc1751'. Allows the mnemonic to generate its * secp256k1 seed, or its ed25519 seed. By default, it will generate the secp256k1 seed * to match the rippled `wallet_propose` default algorithm. * @returns A Wallet derived from a mnemonic. * @throws ValidationError if unable to derive private key from mnemonic input. */ public static fromMnemonic( mnemonic: string, opts: { masterAddress?: string derivationPath?: string mnemonicEncoding?: 'bip39' | 'rfc1751' algorithm?: ECDSA } = {}, ): Wallet { if (opts.mnemonicEncoding === 'rfc1751') { return Wallet.fromRFC1751Mnemonic(mnemonic, { masterAddress: opts.masterAddress, algorithm: opts.algorithm, }) } // Otherwise decode using bip39's mnemonic standard if (!validateMnemonic(mnemonic, wordlist)) { throw new ValidationError( 'Unable to parse the given mnemonic using bip39 encoding', ) } const seed = mnemonicToSeedSync(mnemonic) const masterNode = HDKey.fromMasterSeed(seed) const node = masterNode.derive( opts.derivationPath ?? DEFAULT_DERIVATION_PATH, ) validateKey(node) const publicKey = bytesToHex(node.publicKey) const privateKey = bytesToHex(node.privateKey) return new Wallet(publicKey, `00${privateKey}`, { masterAddress: opts.masterAddress, }) } /** * Derives a wallet from a RFC1751 mnemonic, which is how `wallet_propose` encodes mnemonics. * * @param mnemonic - A string consisting of words (whitespace delimited) used to derive a wallet. * @param opts - (Optional) Options to derive a Wallet. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @param opts.algorithm - The digital signature algorithm to generate an address for. * @returns A Wallet derived from a mnemonic. */ private static fromRFC1751Mnemonic( mnemonic: string, opts: { masterAddress?: string; algorithm?: ECDSA }, ): Wallet { const seed = rfc1751MnemonicToKey(mnemonic) let encodeAlgorithm: 'ed25519' | 'secp256k1' if (opts.algorithm === ECDSA.ed25519) { encodeAlgorithm = 'ed25519' } else { // Defaults to secp256k1 since that's the default for `wallet_propose` encodeAlgorithm = 'secp256k1' } const encodedSeed = encodeSeed(seed, encodeAlgorithm) return Wallet.fromSeed(encodedSeed, { masterAddress: opts.masterAddress, algorithm: opts.algorithm, }) } /** * Derive a Wallet from a seed. * * @param seed - The seed used to derive the wallet. * @param opts - (Optional) Options to derive a Wallet. * @param opts.algorithm - The digital signature algorithm to generate an address for. * @param opts.masterAddress - Include if a Wallet uses a Regular Key Pair. It must be the master address of the account. * @returns A Wallet derived from the seed. */ private static deriveWallet( seed: string, opts: { masterAddress?: string; algorithm?: ECDSA } = {}, ): Wallet { const { publicKey, privateKey } = deriveKeypair(seed, { algorithm: opts.algorithm ?? DEFAULT_ALGORITHM, }) return new Wallet(publicKey, privateKey, { seed, masterAddress: opts.masterAddress, }) } /** * Signs a transaction offline. * * @example * * ```ts * const { Client, Wallet } = require('xrpl') * const client = new Client('wss://s.altnet.rippletest.net:51233') * * async function signTransaction() { * await client.connect() * const { balance: balance1, wallet: wallet1 } = client.fundWallet() * const { balance: balance2, wallet: wallet2 } = client.fundWallet() * * const transaction = { * TransactionType: 'Payment', * Account: wallet1.address, * Destination: wallet2.address, * Amount: '10' * } * * try { * await client.autofill(transaction) * const { tx_blob: signed_tx_blob, hash} = await wallet1.sign(transaction) * console.log(signed_tx_blob) * } catch (error) { * console.error(`Failed to sign transaction: ${error}`) * } * const result = await client.submit(signed_tx_blob) * await client.disconnect() * } * * signTransaction() * ``` * In order for a transaction to be validated, it must be signed by the account sending the transaction to prove * That the owner is actually the one deciding to take that action. * * In this example, we created, signed, and then submitted a transaction to testnet. You may notice that the * Output of `sign` includes a `tx_blob` and a `hash`, both of which are needed to submit & verify the results. * Note: If you pass a `Wallet` to `client.submit` or `client.submitAndWait` it will do signing like this under the hood. * * `tx_blob` is a binary representation of a transaction on the XRP Ledger. It's essentially a byte array * that encodes all of the data necessary to execute the transaction, including the source address, the destination * address, the amount, and any additional fields required for the specific transaction type. * * `hash` is a unique identifier that's generated from the signed transaction data on the XRP Ledger. It's essentially * A cryptographic digest of the signed transaction blob, created using a hash function. The signed transaction hash is * Useful for identifying and tracking specific transactions on the XRP Ledger. It can be used to query transaction * Information, verify the authenticity of a transaction, and detect any tampering with the transaction data. * * @param this - Wallet instance. * @param transaction - A transaction to be signed offline. * @param multisign - Specify true/false to use multisign or actual address (classic/x-address) to make multisign tx request. * The actual address is only needed in the case of regular key usage. * @returns A signed transaction. * @throws ValidationError if the transaction is already signed or does not encode/decode to same result. * @throws XrplError if the issued currency being signed is XRP ignoring case. */ // eslint-disable-next-line max-lines-per-function -- introduced more checks to support both string and boolean inputs. public sign( this: Wallet, transaction: Transaction, multisign?: boolean | string, ): { tx_blob: string hash: string } { let multisignAddress: boolean | string = false if (typeof multisign === 'string') { multisignAddress = multisign } else if (multisign) { multisignAddress = this.classicAddress } // clean null & undefined valued tx properties // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- ensure Transaction flows through const tx = omitBy( { ...transaction }, (value) => value == null, ) as unknown as Transaction if (tx.TxnSignature || tx.Signers) { throw new ValidationError( 'txJSON must not contain "TxnSignature" or "Signers" properties', ) } removeTrailingZeros(tx) /* * This will throw a more clear error for JS users if the supplied transaction has incorrect formatting */ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- validate does not accept Transaction type validate(tx as unknown as Record<string, unknown>) if (hasFlag(tx, GlobalFlags.tfInnerBatchTxn, 'tfInnerBatchTxn')) { throw new ValidationError('Cannot sign a Batch inner transaction.') } const txToSignAndEncode = { ...tx } if (multisignAddress) { txToSignAndEncode.SigningPubKey = '' const signer = { Account: multisignAddress, SigningPubKey: this.publicKey, TxnSignature: computeSignature( txToSignAndEncode, this.privateKey, multisignAddress, ), } txToSignAndEncode.Signers = [{ Signer: signer }] } else { txToSignAndEncode.SigningPubKey = this.publicKey txToSignAndEncode.TxnSignature = computeSignature( txToSignAndEncode, this.privateKey, ) } const serialized = encode(txToSignAndEncode) return { tx_blob: serialized, hash: hashSignedTx(serialized), } } /** * Verifies a signed transaction offline. * * @param signedTransaction - A signed transaction (hex string of signTransaction result) to be verified offline. * @returns Returns true if a signedTransaction is valid. * @throws {Error} Transaction is missing a signature, TxnSignature */ public verifyTransaction(signedTransaction: Transaction | string): boolean { return verifySignature(signedTransaction, this.publicKey) } /** * Gets an X-address in Testnet/Mainnet format. * * @param tag - A tag to be included within the X-address. * @param isTestnet - A boolean to indicate if X-address should be in Testnet (true) or Mainnet (false) format. * @returns An X-address. */ public getXAddress(tag: number | false = false, isTestnet = false): string { return classicAddressToXAddress(this.classicAddress, tag, isTestnet) } } /** * Signs a transaction with the proper signing encoding. * * @param tx - A transaction to sign. * @param privateKey - A key to sign the transaction with. * @param signAs - Multisign only. An account address to include in the Signer field. * Can be either a classic address or an XAddress. * @returns A signed transaction in the proper format. */ function computeSignature( tx: Transaction, privateKey: string, signAs?: string, ): string { if (signAs) { const classicAddress = isValidXAddress(signAs) ? xAddressToClassicAddress(signAs).classicAddress : signAs return sign(encodeForMultisigning(tx, classicAddress), privateKey) } return sign(encodeForSigning(tx), privateKey) } /** * Remove trailing insignificant zeros for non-XRP Payment amount. * This resolves the serialization mismatch bug when encoding/decoding a non-XRP Payment transaction * with an amount that contains trailing insignificant zeros; for example, '123.4000' would serialize * to '123.4' and cause a mismatch. * * @param tx - The transaction prior to signing. */ function removeTrailingZeros(tx: Transaction): void { if ( tx.TransactionType === 'Payment' && typeof tx.Amount !== 'string' && tx.Amount.value.includes('.') && tx.Amount.value.endsWith('0') ) { // eslint-disable-next-line no-param-reassign -- Required to update Transaction.Amount.value tx.Amount = { ...tx.Amount } // eslint-disable-next-line no-param-reassign -- Required to update Transaction.Amount.value tx.Amount.value = new BigNumber(tx.Amount.value).toString() } }