From a803e11d3dbe0a202c3f6efa535a4e08e9ea260f Mon Sep 17 00:00:00 2001 From: interc0der Date: Wed, 13 Aug 2025 20:42:54 -0500 Subject: [PATCH 1/2] fix: add methods to support Wallet.fromPrivateKey --- packages/xrpl/src/Wallet/index.ts | 16 +++++++++ packages/xrpl/test/wallet/index.test.ts | 45 +++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/packages/xrpl/src/Wallet/index.ts b/packages/xrpl/src/Wallet/index.ts index 4927a3dfde..5ded8de5f7 100644 --- a/packages/xrpl/src/Wallet/index.ts +++ b/packages/xrpl/src/Wallet/index.ts @@ -17,6 +17,7 @@ import { import { deriveAddress, deriveKeypair, + derivePublicKey, generateSeed, sign, } from 'ripple-keypairs' @@ -173,6 +174,21 @@ export class Wallet { }) } + /** + * Derives a wallet from a private key. + * + * @param privateKey - A string used to generate a keypair (publicKey/privateKey) to derive a wallet. + * @returns A Wallet derived from a private key. + * + * @throws ValidationError if private key is not a valid string + */ + public static fromPrivateKey(privateKey: string): Wallet { + if (!privateKey || typeof privateKey !== 'string') { + throw new ValidationError('privateKey must be a non-empty string') + } + return new Wallet(derivePublicKey(privateKey), privateKey) + } + /** * Derives a wallet from a secret (AKA a seed). * diff --git a/packages/xrpl/test/wallet/index.test.ts b/packages/xrpl/test/wallet/index.test.ts index cf564173ea..33795e3df4 100644 --- a/packages/xrpl/test/wallet/index.test.ts +++ b/packages/xrpl/test/wallet/index.test.ts @@ -437,6 +437,51 @@ describe('Wallet', function () { }) }) + describe('from PrivateKey', function () { + describe('using secp256k1 private key', function () { + const mockWallet_secp256k1 = { + address: 'rhvh5SrgBL5V8oeV9EpDuVszeJSSCEkbPc', + publicKey: + '030E58CDD076E798C84755590AAF6237CA8FAE821070A59F648B517A30DC6F589D', + privateKey: + '00141BA006D3363D2FB2785E8DF4E44D3A49908780CB4FB51F6D217C08C021429F', + } + + it('derive keypair from private key', function () { + const wallet = Wallet.fromPrivateKey(mockWallet_secp256k1.privateKey) + assert.equal(wallet.address, mockWallet_secp256k1.address) + assert.equal(wallet.publicKey, mockWallet_secp256k1.publicKey) + }) + + it('throws error for malformed secp256k1 private key', function () { + assert.throws(() => + Wallet.fromPrivateKey(mockWallet_secp256k1.privateKey.slice(0, 10)), + ) + }) + }) + + describe('using ed25519 private key', function () { + const mockWallet_ed25519 = { + address: 'rszPLM97iS8mFTndKQNexGhY1N9ionLVAx', + publicKey: + 'EDFD5C3E305FDEB97A89FC39ED333A710A7ED35E3471443C4989F9E3B8F023488D', + privateKey: + 'EDDA8694C151CE30E8A2C91884E26BC11A75514E3A27EE6CE4615FABA3DCBE1429', + } + it('derive keypair from private key', function () { + const wallet = Wallet.fromPrivateKey(mockWallet_ed25519.privateKey) + assert.equal(wallet.address, mockWallet_ed25519.address) + assert.equal(wallet.publicKey, mockWallet_ed25519.publicKey) + }) + + it('throws error for malformed ed25519 private key', function () { + assert.throws(() => + Wallet.fromPrivateKey(mockWallet_ed25519.privateKey.slice(0, 10)), + ) + }) + }) + }) + // eslint-disable-next-line max-statements -- Required for test coverage. describe('sign', function () { let wallet: Wallet From 91557dd440d1b8bb823af1d189066550a48e9936 Mon Sep 17 00:00:00 2001 From: interc0der Date: Wed, 13 Aug 2025 20:43:07 -0500 Subject: [PATCH 2/2] fix: add methods to support Wallet.fromPrivateKey --- packages/ripple-keypairs/src/index.ts | 14 +++++++++++++ .../src/signing-schemes/ed25519/index.ts | 21 +++++++++++++++++++ .../src/signing-schemes/secp256k1/index.ts | 20 ++++++++++++++++++ packages/ripple-keypairs/src/types.ts | 2 ++ 4 files changed, 57 insertions(+) diff --git a/packages/ripple-keypairs/src/index.ts b/packages/ripple-keypairs/src/index.ts index f41326e535..4c1e253aa6 100644 --- a/packages/ripple-keypairs/src/index.ts +++ b/packages/ripple-keypairs/src/index.ts @@ -95,6 +95,19 @@ function deriveAddress(publicKey: string): string { return deriveAddressFromBytes(hexToBytes(publicKey)) } +function derivePublicKey(privateKey: string): string { + const algorithm = getAlgorithmFromPrivateKey(privateKey) + + if (algorithm === 'ecdsa-secp256k1') { + return secp256k1.deriveKeypairFromPrivateKey(privateKey).publicKey + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (algorithm === 'ed25519') { + return ed25519.deriveKeypairFromPrivateKey(privateKey).publicKey + } + throw new Error('Unknown signing scheme algorithm') +} + function deriveNodeAddress(publicKey: string): string { const generatorBytes = decodeNodePublic(publicKey) const accountPublicBytes = accountPublicFromPublicGenerator(generatorBytes) @@ -107,6 +120,7 @@ export { sign, verify, deriveAddress, + derivePublicKey, deriveNodeAddress, decodeSeed, } diff --git a/packages/ripple-keypairs/src/signing-schemes/ed25519/index.ts b/packages/ripple-keypairs/src/signing-schemes/ed25519/index.ts index dae0d12c83..e88635fc78 100644 --- a/packages/ripple-keypairs/src/signing-schemes/ed25519/index.ts +++ b/packages/ripple-keypairs/src/signing-schemes/ed25519/index.ts @@ -19,6 +19,27 @@ const ed25519: SigningScheme = { return { privateKey, publicKey } }, + deriveKeypairFromPrivateKey(privateKey: string): { + privateKey: string + publicKey: string + } { + assert.ok( + privateKey.startsWith(ED_PREFIX) + ? privateKey.length === 66 + : privateKey.length === 64, + 'Invalid ed25519 private key length', + ) + + const normalizedPrivateKey = privateKey.startsWith(ED_PREFIX) + ? privateKey.slice(2) + : privateKey + + const buffer = Buffer.from(normalizedPrivateKey, 'hex') + + const publicKey = ED_PREFIX + bytesToHex(nobleEd25519.getPublicKey(buffer)) + return { privateKey, publicKey } + }, + sign(message: Uint8Array, privateKey: HexString): string { assert.ok(message instanceof Uint8Array, 'message must be array of octets') assert.ok( diff --git a/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts b/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts index e1bd3019a1..60fa1485bc 100644 --- a/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts +++ b/packages/ripple-keypairs/src/signing-schemes/secp256k1/index.ts @@ -30,6 +30,26 @@ const secp256k1: SigningScheme = { return { privateKey, publicKey } }, + deriveKeypairFromPrivateKey(privateKey: string): { + privateKey: string + publicKey: string + } { + assert.ok( + (privateKey.length === 66 && privateKey.startsWith(SECP256K1_PREFIX)) || + privateKey.length === 64, + 'Invalid private key length or format', + ) + const normalizedPrivateKey = + privateKey.length === 66 && privateKey.startsWith(SECP256K1_PREFIX) + ? privateKey.slice(2) + : privateKey + + const buffer = Buffer.from(normalizedPrivateKey, 'hex') + + const publicKey = bytesToHex(nobleSecp256k1.getPublicKey(buffer, true)) + return { privateKey, publicKey } + }, + sign(message: Uint8Array, privateKey: HexString): string { // Some callers pass the privateKey with the prefix, others without. // @noble/curves will throw if the key is not exactly 32 bytes, so we diff --git a/packages/ripple-keypairs/src/types.ts b/packages/ripple-keypairs/src/types.ts index dbeb0a1b38..e18076f5fd 100644 --- a/packages/ripple-keypairs/src/types.ts +++ b/packages/ripple-keypairs/src/types.ts @@ -18,6 +18,8 @@ export interface SigningScheme { options?: DeriveKeyPairOptions, ) => KeyPair + deriveKeypairFromPrivateKey: (privateKey: HexString) => KeyPair + sign: ( // deriveKeyPair creates a Sha512.half as Uint8Array so that's why it takes this // though it /COULD/ take HexString as well