-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: xrpl DR script [SIGNER-140] (#17)
* feat: print out pubkey for XRPL tool * feat: add xrpl-tool recovery script * chore: update README * chore: add README for xrpl-tool * Update README.md * chore: bump version v5.1.0
- Loading branch information
1 parent
bc51c92
commit c6f83d1
Showing
8 changed files
with
601 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
.DS_Store | ||
.idea | ||
bin/* | ||
**/node_modules | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
# XRP Transfer Tool | ||
|
||
A command-line tool for transferring XRP using EdDSA keypairs. This tool | ||
allows you to: | ||
- Import EdDSA keypairs in the scalar format used by the DR tool | ||
- Check wallet balances (optional) | ||
- Create and sign XRP transfer transactions | ||
- Broadcast transactions to the XRPL network (optional) | ||
|
||
## Prerequisites | ||
|
||
- Node.js 20 or higher | ||
- npm or yarn | ||
|
||
## Installation | ||
|
||
1. Clone this repository or download the files | ||
2. Install dependencies: | ||
```bash | ||
npm i | ||
``` | ||
|
||
## Running the Tool | ||
|
||
Run the script using tsx: | ||
```bash | ||
npx tsx main.ts | ||
``` | ||
|
||
## Usage Flow | ||
|
||
1. Choose network (testnet/mainnet) | ||
2. Enter your EdDSA keypair: | ||
- Public key (64 hex chars) | ||
- Private key (64 hex chars) | ||
3. View your wallet address | ||
4. Optionally check your wallet balance | ||
5. Create a transfer: | ||
- Enter destination address | ||
- Enter amount of XRP | ||
6. Review the signed transaction | ||
7. Choose to broadcast now or save for later | ||
|
||
## Offline Usage | ||
|
||
The tool can work offline except for: | ||
|
||
- Balance checking (optional) | ||
- Transaction broadcasting (optional) | ||
|
||
If you choose not to broadcast immediately, you'll receive instructions for | ||
broadcasting the signed transaction later using other tools. | ||
|
||
## Security Notes | ||
|
||
- Your private key is never stored or transmitted | ||
- The tool can be used offline for the most part | ||
- Always verify transaction details before broadcasting | ||
- Use testnet first to familiarize yourself with the tool | ||
|
||
## Example Keys Format | ||
|
||
Public and private keys should be in raw hex format (64 characters each). | ||
Example: | ||
|
||
- Public key: | ||
`0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF` | ||
- Private key: | ||
`0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF` | ||
|
||
## Error Handling | ||
|
||
The tool includes validation for: | ||
|
||
- Key format and length | ||
- XRP amount validity | ||
- Destination address format | ||
- Available balance (if checked) | ||
- Network connectivity issues |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,254 @@ | ||
import { Client, decode, encode, encodeForSigning, Transaction, verifySignature, Wallet, xrpToDrops } from 'xrpl'; | ||
import readlineSync from 'readline-sync'; | ||
import { webcrypto } from 'crypto'; | ||
import * as ed from '@noble/ed25519'; | ||
import { bytesToNumberBE, bytesToNumberLE, numberToBytesBE, numberToBytesLE } from '@noble/curves/abstract/utils'; | ||
import { hashSignedTx } from 'xrpl/dist/npm/utils/hashes'; | ||
import { sha512 } from '@noble/hashes/sha512'; | ||
|
||
// polyfill for Node.js | ||
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m)); | ||
|
||
const TESTNET_URL = 'wss://testnet.xrpl-labs.com'; | ||
const MAINNET_URL = 'wss://s1.ripple.com'; | ||
|
||
function validateHexKey(key, length) { | ||
if (!key.match(/^[0-9a-fA-F]+$/)) { | ||
return false; | ||
} | ||
return key.length === length * 2; | ||
} | ||
|
||
function validateXRPAmount(amount) { | ||
const num = Number(amount); | ||
return !isNaN(num) && num > 0 && num <= 100000000000; | ||
} | ||
|
||
function validateDestinationAddress(address) { | ||
return address.match(/^r[1-9A-HJ-NP-Za-km-z]{25,34}$/); | ||
} | ||
|
||
async function main() { | ||
console.log('XRP Transfer Tool\n'); | ||
|
||
const useMainNet = readlineSync.keyInYNStrict('Would you like to use mainnet? (No for testnet)'); | ||
const rpcUrl = !useMainNet ? TESTNET_URL : MAINNET_URL; | ||
|
||
console.log('\nPlease enter your EdDSA keypair from the DR tool (64 bytes each, in hexadecimal):'); | ||
|
||
let publicKey, privateKey; | ||
do { | ||
publicKey = readlineSync.question('Public Key (64 hex chars): '); | ||
if (!validateHexKey(publicKey, 32)) { | ||
console.log('Invalid public key format. Must be 64 hexadecimal characters.'); | ||
} | ||
} while (!validateHexKey(publicKey, 32)); | ||
|
||
do { | ||
privateKey = readlineSync.question('Private Key (64 hex chars): ', { | ||
hideEchoBack: true | ||
}); | ||
if (!validateHexKey(privateKey, 32)) { | ||
console.log('Invalid private key format. Must be 64 hexadecimal characters.'); | ||
} | ||
} while (!validateHexKey(privateKey, 32)); | ||
|
||
const wallet = new Wallet('ed' + publicKey, 'ed' + privateKey); | ||
console.log(`\nWallet address: ${wallet.address}`); | ||
|
||
const checkBalance = readlineSync.keyInYNStrict('\nWould you like to check the wallet balance? (requires network connection)'); | ||
|
||
let xrpBalance; | ||
if (checkBalance) { | ||
try { | ||
const client = new Client(rpcUrl); | ||
await client.connect(); | ||
|
||
const accountInfo = await client.request({ | ||
command: 'account_info', | ||
account: wallet.address, | ||
ledger_index: 'validated' | ||
}); | ||
|
||
xrpBalance = Number(accountInfo.result.account_data.Balance) / 1000000; | ||
console.log(`Balance: ${xrpBalance} XRP`); | ||
|
||
await client.disconnect(); | ||
} catch (error) { | ||
console.error('Error fetching balance:', error.message); | ||
console.log('Continuing in offline mode...'); | ||
} | ||
} | ||
|
||
const wantToTransfer = readlineSync.keyInYNStrict('\nWould you like to transfer XRP?'); | ||
if (!wantToTransfer) { | ||
console.log('Exiting...'); | ||
return; | ||
} | ||
|
||
// Get and validate destination address | ||
let destinationAddress; | ||
do { | ||
destinationAddress = readlineSync.question('\nEnter destination address: '); | ||
if (!validateDestinationAddress(destinationAddress)) { | ||
console.log('Invalid destination address format. Must be a valid XRPL address starting with "r".'); | ||
} | ||
} while (!validateDestinationAddress(destinationAddress)); | ||
|
||
// Get and validate amount | ||
let amount; | ||
do { | ||
amount = readlineSync.question('\nEnter amount of XRP to send: '); | ||
if (!validateXRPAmount(amount)) { | ||
console.log('Invalid amount. Must be a positive number less than 100 billion XRP.'); | ||
} else if (xrpBalance !== undefined && Number(amount) > xrpBalance) { | ||
console.log('Amount exceeds available balance.'); | ||
continue; | ||
} | ||
break; | ||
} while (true); | ||
|
||
console.log('We must be online to fetch the XRP ledger sequence and fees data. Please ensure you have network connectivity.'); | ||
try { | ||
const client = new Client(rpcUrl); | ||
await client.connect(); | ||
|
||
// Prepare transaction | ||
const tx = await client.autofill({ | ||
TransactionType: 'Payment', | ||
Account: wallet.address, | ||
Destination: destinationAddress, | ||
Amount: xrpToDrops(amount), | ||
}) as Transaction; | ||
tx.SigningPubKey = wallet.publicKey; | ||
if (tx.LastLedgerSequence) { | ||
//** Adds 15 minutes worth of ledgers (assuming 4 ledgers per second) to the existing LastLedgerSequence value. */ | ||
tx.LastLedgerSequence = tx.LastLedgerSequence + 15 * 60 * 4; | ||
} | ||
|
||
// Sign transaction | ||
const preImageHex = encodeForSigning(tx); | ||
console.log('Transaction Pre-image:', encodeForSigning(tx)); | ||
|
||
const { signature } = await signWithScalar(preImageHex, privateKey); | ||
tx.TxnSignature = signature; | ||
console.log('Transaction Details:', tx); | ||
|
||
const encodedTxHex = encode(tx); | ||
const signedTxHash = hashSignedTx(encodedTxHex); | ||
console.log('\nSigned transaction hex:'); | ||
console.log(encodedTxHex); | ||
|
||
if (!verifySignature(encodedTxHex, tx.SigningPubKey)) throw new Error('Signature verification failed'); | ||
|
||
const wantToBroadcast = readlineSync.keyInYNStrict('\nWould you like to broadcast this transaction now?'); | ||
|
||
if (wantToBroadcast) { | ||
console.log('\nBroadcasting transaction...'); | ||
const submit = await client.submit(encodedTxHex); | ||
console.log(`Initial status: ${submit.result.engine_result_message}`); | ||
|
||
if (submit.result.engine_result.includes('SUCCESS')) { | ||
console.log('\nWaiting for validation...'); | ||
const txResponse = await client.request({ | ||
command: 'tx', | ||
transaction: signedTxHash, | ||
binary: false | ||
}); | ||
|
||
if (txResponse.result.validated) { | ||
console.log('\nTransaction validated!'); | ||
console.log(`Transaction hash: ${signedTxHash}`); | ||
} else { | ||
console.log('\nTransaction not yet validated. You can check status later with hash:'); | ||
console.log(signedTxHash); | ||
} | ||
} else { | ||
console.log('\nTransaction failed to submit.'); | ||
console.log(`Error: ${submit.result.engine_result_message}`); | ||
} | ||
} else { | ||
console.log('\nTo broadcast this transaction later:'); | ||
console.log('1. Use the XRPL CLI tool: xrpl submit <tx_blob>'); | ||
console.log('2. Or use any XRPL node\'s RPC interface with the submit method'); | ||
} | ||
|
||
await client.disconnect(); | ||
} catch (error) { | ||
console.error('Error:', error.data?.error_exception || error.message); | ||
console.log('Please ensure you have network connectivity to prepare and broadcast the transaction.'); | ||
process.exit(1); | ||
} | ||
} | ||
|
||
main().catch(console.error); | ||
|
||
async function signWithScalar(messageHex: string, privateKeyHex: string): Promise<{ signature: string, publicKey: string }> { | ||
// Remove '0x' prefix if present from inputs | ||
messageHex = messageHex.replace(/^0x/, ''); | ||
privateKeyHex = privateKeyHex.replace(/^0x/, ''); | ||
|
||
// Convert hex message to Uint8Array | ||
const message = Buffer.from(messageHex, 'hex'); | ||
|
||
// Convert hex private key scalar to Uint8Array | ||
const privateKeyBytes = Buffer.from(privateKeyHex, 'hex'); | ||
const scalar = bytesToNumberBE(privateKeyBytes); | ||
if (scalar >= ed.CURVE.n) { | ||
throw new Error('Private key scalar must be less than curve order'); | ||
} | ||
|
||
// Validate private key length (32 bytes for Ed25519) | ||
if (privateKeyBytes.length !== 32) { | ||
throw new Error('Private key must be 32 bytes'); | ||
} | ||
|
||
// Calculate public key directly from private key scalar | ||
const publicKey = ed.ExtendedPoint.BASE.multiply(bytesToNumberBE(privateKeyBytes)).toRawBytes(); | ||
|
||
// Note: This nonce generation differs from standard Ed25519, which uses | ||
// the first half of SHA-512(private_key_seed). We're creating a deterministic | ||
// nonce from the raw scalar and message instead. | ||
const nonceInput = new Uint8Array([...privateKeyBytes, ...message]); | ||
const nonceArrayBuffer = await webcrypto.subtle.digest('SHA-512', nonceInput); | ||
const nonceArray = new Uint8Array(nonceArrayBuffer); | ||
|
||
// Reduce nonce modulo L (Ed25519 curve order) | ||
const reducedNonce = bytesToNumberLE(nonceArray) % ed.CURVE.n; | ||
|
||
// Calculate R = k * G | ||
const R = ed.ExtendedPoint.BASE.multiply(reducedNonce); | ||
|
||
// Calculate S = (r + H(R,A,m) * s) mod L | ||
const hramInput = new Uint8Array([ | ||
...R.toRawBytes(), | ||
...publicKey, | ||
...message | ||
]); | ||
|
||
const hArrayBuffer = await webcrypto.subtle.digest('SHA-512', hramInput); | ||
const h = new Uint8Array(hArrayBuffer); | ||
const hnum = bytesToNumberLE(h) % ed.CURVE.n; | ||
|
||
const s = bytesToNumberBE(privateKeyBytes); | ||
const S = (reducedNonce + (hnum * s)) % ed.CURVE.n; | ||
|
||
// Combine R and S to form signature | ||
const signature = new Uint8Array([ | ||
...R.toRawBytes(), | ||
...numberToBytesLE(S, 32) | ||
]); | ||
|
||
// Convert outputs to hex strings | ||
return { | ||
signature: bytesToHex(signature), | ||
publicKey: bytesToHex(publicKey) | ||
}; | ||
} | ||
|
||
// Helper function to convert Uint8Array to hex string | ||
function bytesToHex(bytes: Uint8Array): string { | ||
return Array.from(bytes) | ||
.map(b => b.toString(16).padStart(2, '0')) | ||
.join(''); | ||
} |
Oops, something went wrong.