diff --git a/README.md b/README.md index a0ec869..de964a0 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,19 @@ This command will guide you through the process of wallet management, offering o ### 2. Check Balance -The `balance` command allows you to check the balance of any token on the Rootstock blockchain for any of the saved wallets. You can check the balance on either the mainnet or testnet using the appropriate flags. +The `balance` command allows you to check the balance of any token on the Rootstock blockchain for any of the saved wallets. You can check the balance on either the mainnet or testnet using the appropriate flags. The command now supports RNS domain resolution for checking balances of any address. #### Mainnet ```bash +# Check balance of your wallet rsk-cli balance + +# Check balance of a specific address +rsk-cli balance --address 0x123... + +# Check balance using RNS domain (example with real mainnet domain) +rsk-cli balance --rns testing.rsk ``` Output example: @@ -161,7 +168,11 @@ Output example: Use the `-t` or `--testnet` flag to check the balance on the Rootstock testnet. ```bash +# Check balance on testnet rsk-cli balance -t + +# Check balance using RNS domain on testnet +rsk-cli balance -t --rns testing.rsk ``` Output example: @@ -188,7 +199,7 @@ rsk-cli balance --wallet ### 3. Transfer (RBTC and ERC20) -The `transfer` command allows you to transfer both RBTC and ERC20 tokens from your saved wallet to a specified address on the Rootstock blockchain. You can execute transfers on either mainnet or testnet using the appropriate flags. +The `transfer` command allows you to transfer both RBTC and ERC20 tokens from your saved wallet to a specified address on the Rootstock blockchain. You can execute transfers on either mainnet or testnet using the appropriate flags. The command now supports RNS domain resolution for recipient addresses. #### Interactive Mode @@ -205,9 +216,12 @@ rsk-cli transfer -i #### For RBTC Transfer ```bash -# Basic transfer on mainnet +# Basic transfer on mainnet using address rsk-cli transfer --address 0xRecipientAddress --value 0.001 +# Transfer using RNS domain +rsk-cli transfer --rns testing.rsk --value 0.001 + # Transfer on testnet rsk-cli transfer --testnet --address 0x08C4E4BdAb2473E454B8B2a4400358792786d341 --value 0.001 @@ -237,12 +251,18 @@ Output example for RBTC transfer: Add the `--token` flag with the token contract address to transfer ERC20 tokens: ```bash -# Basic token transfer on mainnet +# Basic token transfer on mainnet using address rsk-cli transfer --token 0xTokenAddress --address 0xRecipientAddress --value 0.1 +# Token transfer using RNS domain +rsk-cli transfer --token 0xTokenAddress --rns testing.rsk --value 0.1 + # Token transfer on testnet rsk-cli transfer --testnet --token 0x32Cd6c5831531F96f57d1faf4DDdf0222c4Af8AB --address 0x8A0d290b2EE35eFde47810CA8fF057e109e4190B --value 0.1 +# Token transfer on testnet using RNS domain +rsk-cli transfer --testnet --token 0x32Cd6c5831531F96f57d1faf4DDdf0222c4Af8AB --rns testing.rsk --value 0.1 + # Using specific wallet rsk-cli transfer --wallet --testnet --token 0x32Cd6c5831531F96f57d1faf4DDdf0222c4Af8AB --address 0x8A0d290b2EE35eFde47810CA8fF057e109e4190B --value 0.1 @@ -501,9 +521,9 @@ Output example: Time: Tue Nov 12 2024 11:46:32 GMT+0700 (Indochina Time) ``` -### 9. Fetch Wallet History +### 10. Batch Transfer -The batch-transfer command allows you to send multiple transactions in one batch. This feature supports both interactive mode (manual input) and file-based batch processing, enabling you to transfer rBTC to multiple addresses in a single operation. +The batch-transfer command allows you to send multiple transactions in one batch. This feature supports both interactive mode (manual input) and file-based batch processing, enabling you to transfer rBTC to multiple addresses in a single operation. The command now supports RNS domain resolution for recipient addresses. #### Interactive Mode @@ -512,13 +532,21 @@ In this mode, the CLI will prompt you to enter the recipient addresses and amoun #### Mainnet ```bash +# Interactive mode without RNS rsk-cli batch-transfer --interactive + +# Interactive mode with RNS support +rsk-cli batch-transfer --interactive --rns ``` #### Testnet ```bash +# Interactive mode without RNS rsk-cli batch-transfer --testnet --interactive + +# Interactive mode with RNS support +rsk-cli batch-transfer --testnet --interactive --rns ``` Output example: @@ -545,7 +573,7 @@ Add another transaction? (y/n): n #### File-based -In this mode, you provide a JSON file containing the batch transactions. The file must include a list of transactions, each specifying the recipient address (address) and the amount (amount). The file should look something like this: +In this mode, you provide a JSON file containing the batch transactions. The file must include a list of transactions, each specifying the recipient address (address) and the amount (amount). With RNS support, you can also use domain names as recipients. The file should look something like this: ```json [ @@ -554,16 +582,34 @@ In this mode, you provide a JSON file containing the batch transactions. The fil ] ``` +Or with RNS domains (when using --rns flag): + +```json +[ + { "to": "testing.rsk", "value": 0.000001 }, + { "to": "rifos.rsk", "value": 0.000001 }, + { "to": "0xDdC94BFde7C64117F35803AeA4FA4F98A7b4f57C", "value": 0.000001 } +] +``` + #### Mainnet ```bash +# File-based batch transfer rsk-cli batch-transfer --file + +# File-based batch transfer with RNS resolution +rsk-cli batch-transfer --file --rns ``` #### Testnet ```bash +# File-based batch transfer rsk-cli batch-transfer --testnet --file + +# File-based batch transfer with RNS resolution +rsk-cli batch-transfer --testnet --file --rns ``` Output example: @@ -582,6 +628,67 @@ Output example: ⛽ Gas Used: 21000 ``` +### 11. RNS Resolve + +The `resolve` command allows you to interact with the RIF Name Service (RNS) on the Rootstock blockchain. You can perform both forward resolution (domain to address) and reverse resolution (address to domain name). + +#### Forward Resolution (Domain to Address) + +Convert an RNS domain name to its associated address: + +##### Mainnet + +```bash +rsk-cli resolve testing.rsk +``` + +##### Testnet + +```bash +rsk-cli resolve testing.rsk --testnet +``` + +Output example: + +``` +🔍 Resolving testing.rsk... +✅ Domain resolved successfully! +🏷️ Domain: testing.rsk +📄 Address: 0x0000000000000000000000000000000001000006 +🌐 Network: Rootstock Mainnet +``` + +#### Reverse Resolution (Address to Domain) + +Convert an address back to its RNS domain name: + +##### Mainnet + +```bash +rsk-cli resolve 0x123456789abcdef0123456789abcdef012345678 --reverse +``` + +##### Testnet + +```bash +rsk-cli resolve 0x123456789abcdef0123456789abcdef012345678 --reverse --testnet +``` + +Output example: + +``` +🔍 Resolving address: 0x123456789abcdef0123456789abcdef012345678 +✅ Resolution successful! +📍 Address: 0x123456789abcdef0123456789abcdef012345678 +📌 Name: alice.rsk +🌐 Network: Rootstock Testnet +``` + +> **Note**: +> - The `.rsk` extension is automatically appended if not provided +> - Both checksummed and non-checksummed addresses are supported +> - The command will show appropriate error messages if the name or address cannot be resolved + ## Contributing We welcome contributions from the community. Please fork the repository and submit pull requests with your changes. Ensure your code adheres to the project's main objective. diff --git a/bin/index.ts b/bin/index.ts index 2c7e104..3a58a04 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -14,8 +14,11 @@ import { bridgeCommand } from "../src/commands/bridge.js"; import { batchTransferCommand } from "../src/commands/batchTransfer.js"; import { historyCommand } from "../src/commands/history.js"; import { selectAddress } from "../src/commands/selectAddress.js"; +import { resolveCommand } from "../src/commands/resolve.js"; import { transactionCommand } from "../src/commands/transaction.js"; import { parseEther } from "viem"; +import { resolveRNSToAddress } from "../src/utils/rnsHelper.js"; +import { validateAndFormatAddressRSK } from "../src/utils/index.js"; interface CommandOptions { testnet?: boolean; @@ -35,9 +38,11 @@ interface CommandOptions { file?: string; interactive?: boolean; token?: Address; + reverse?: boolean; gasLimit?: string; gasPrice?: string; data?: string; + rns?: string; } const orange = chalk.rgb(255, 165, 0); @@ -73,10 +78,26 @@ program .description("Check the balance of the saved wallet") .option("-t, --testnet", "Check the balance on the testnet") .option("--wallet ", "Name of the wallet") + .option("-a ,--address
", "Token holder address") + .option("--rns ", "Token holder RNS domain (e.g., alice.rsk)") .action(async (options: CommandOptions) => { + let holderAddress = options.address; + if (options.rns) { + const resolvedAddress = await resolveRNSToAddress({ + name: options.rns, + testnet: !!options.testnet, + isExternal: false + }); + if (!resolvedAddress) { + throw new Error(`Failed to resolve RNS domain: ${options.rns}`); + } + holderAddress = resolvedAddress; + } + await balanceCommand({ testnet: !!options.testnet, walletName: options.wallet!, + address: holderAddress, }); }); @@ -86,6 +107,7 @@ program .option("-t, --testnet", "Transfer on the testnet") .option("--wallet ", "Name of the wallet") .option("-a, --address
", "Recipient address") + .option("--rns ", "Recipient RNS domain (e.g., alice.rsk)") .option("--token
", "ERC20 token contract address (optional, for token transfers)") .option("--value ", "Amount to transfer") .option("-i, --interactive", "Execute interactively and input transactions") @@ -112,9 +134,30 @@ program throw new Error("Invalid value specified for transfer."); } - const address = options.address - ? (`0x${options.address.replace(/^0x/, "")}` as `0x${string}`) - : await selectAddress(); + let address: `0x${string}`; + if (options.rns) { + const resolvedAddress = await resolveRNSToAddress({ + name: options.rns, + testnet: !!options.testnet, + isExternal: false + }); + if (!resolvedAddress) { + throw new Error(`Failed to resolve RNS domain: ${options.rns}`); + } + const formatted = validateAndFormatAddressRSK(resolvedAddress as string, !!options.testnet); + if (!formatted) { + throw new Error(`Invalid resolved address for domain: ${options.rns}`); + } + address = formatted as `0x${string}`; + } else if (options.address) { + const formatted = validateAndFormatAddressRSK(String(options.address), !!options.testnet); + if (!formatted) { + throw new Error("Invalid recipient address"); + } + address = formatted as `0x${string}`; + } else { + address = await selectAddress(); + } const txOptions = { ...(options.gasLimit && { gasLimit: BigInt(options.gasLimit) }), @@ -244,11 +287,13 @@ program .option("-i, --interactive", "Execute interactively and input transactions") .option("-t, --testnet", "Execute on the testnet") .option("-f, --file ", "Execute transactions from a file") + .option("--rns", "Enable RNS domain resolution for recipient addresses") .action(async (options) => { try { const interactive = !!options.interactive; const testnet = !!options.testnet; const file = options.file; + const resolveRNS = !!options.rns; if (interactive && file) { console.error( @@ -263,6 +308,7 @@ program filePath: file, testnet: testnet, interactive: interactive, + resolveRNS: resolveRNS, }); } catch (error: any) { console.error( @@ -272,4 +318,17 @@ program } }); +program + .command("resolve ") + .description("Resolve RNS names to addresses or reverse lookup addresses to names") + .option("-t, --testnet", "Use testnet (currently mainnet only)") + .option("-r, --reverse", "Reverse lookup: address to name") + .action(async (name: string, options: CommandOptions) => { + await resolveCommand({ + name, + testnet: !!options.testnet, + reverse: !!options.reverse + }); + }); + program.parse(process.argv); diff --git a/package-lock.json b/package-lock.json index a9df376..6beb4ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@openzeppelin/contracts": "^5.0.2", + "@rsksmart/rns-resolver.js": "^1.1.0", "@rsksmart/rsk-precompiled-abis": "^6.0.0-ARROWHEAD", "@types/fs-extra": "^11.0.4", "@types/zxcvbn": "^4.4.5", @@ -52,6 +53,25 @@ "node": ">=0.1.90" } }, + "node_modules/@ensdomains/address-encoder": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@ensdomains/address-encoder/-/address-encoder-0.2.22.tgz", + "integrity": "sha512-4it+XDvf0U0vLFECLi4oXFBhelz1I/Mo9AWVgS3zry3tMPUFBzM+a27ndNCPZk+glaQiY+R2GOIa3tYOypK29g==", + "license": "BSD-3-Clause", + "dependencies": { + "bech32": "^2.0.0", + "blakejs": "^1.1.0", + "bn.js": "^4.11.8", + "bs58": "^4.0.1", + "crypto-addr-codec": "^0.1.8", + "js-crc": "^0.2.0", + "js-sha256": "^0.9.0", + "js-sha512": "^0.8.0", + "nano-base32": "^1.0.1", + "ripemd160": "^2.0.2", + "sha3": "^2.1.3" + } + }, "node_modules/@inquirer/checkbox": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.1.5.tgz", @@ -389,6 +409,17 @@ "integrity": "sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==", "license": "MIT" }, + "node_modules/@rsksmart/rns-resolver.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rsksmart/rns-resolver.js/-/rns-resolver.js-1.1.0.tgz", + "integrity": "sha512-h+lF2xTX+Hx/zYBjEMCYuttLDvt0sCs1r2ROoX8dm1wssPnp7hwotsTTwKoO7tTgDHbjNTKORGS1xpjTBrzL5Q==", + "license": "ISC", + "dependencies": { + "@ensdomains/address-encoder": "^0.2.6", + "crypto-addr-codec": "^0.1.7", + "eth-ens-namehash": "^2.0.8" + } + }, "node_modules/@rsksmart/rsk-precompiled-abis": { "version": "6.0.0-ARROWHEAD", "resolved": "https://registry.npmjs.org/@rsksmart/rsk-precompiled-abis/-/rsk-precompiled-abis-6.0.0-ARROWHEAD.tgz", @@ -542,6 +573,95 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/base-x": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", + "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.36", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.36.tgz", + "integrity": "sha512-t70bfa7HYEA1D9idDbmuv7YbsbVkQ+Hp+8KFSul4aE5e/i1bjCNIRYJZlA8Q8p0r9T8cF/RVvwUgRA//FydEyg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/blakejs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/blakejs/-/blakejs-1.2.1.tgz", + "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==", + "license": "MIT" + }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bun-types": { "version": "1.2.13", "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.2.13.tgz", @@ -654,12 +774,43 @@ "node": ">=18" } }, + "node_modules/crypto-addr-codec": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/crypto-addr-codec/-/crypto-addr-codec-0.1.8.tgz", + "integrity": "sha512-GqAK90iLLgP3FvhNmHbpT3wR6dEdaM8hZyZtLX29SPardh3OA13RFLHDR6sntGCgRWOfiHqW6sIyohpNqOtV/g==", + "license": "MIT", + "dependencies": { + "base-x": "^3.0.8", + "big-integer": "1.6.36", + "blakejs": "^1.1.0", + "bs58": "^4.0.1", + "ripemd160-min": "0.0.6", + "safe-buffer": "^5.2.0", + "sha3": "^2.1.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/eth-ens-namehash": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/eth-ens-namehash/-/eth-ens-namehash-2.0.8.tgz", + "integrity": "sha512-VWEI1+KJfz4Km//dadyvBBoBeSQ0MHTXPvr8UIXiLW6IanxvAV+DmlZAijZwAyggqGUfwQBeHf7tc9wzc1piSw==", + "license": "ISC", + "dependencies": { + "idna-uts46-hx": "^2.3.1", + "js-sha3": "^0.5.7" + } + }, + "node_modules/eth-ens-namehash/node_modules/js-sha3": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.5.7.tgz", + "integrity": "sha512-GII20kjaPX0zJ8wzkTbNDYMY7msuZcTWk8S5UOh6806Jq/wz1J8/bnr8uGU0DAUmYDjj2Mr4X1cW8v/GLYnR+g==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -745,6 +896,20 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hash-base": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", + "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -757,6 +922,44 @@ "node": ">=0.10.0" } }, + "node_modules/idna-uts46-hx": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/idna-uts46-hx/-/idna-uts46-hx-2.3.1.tgz", + "integrity": "sha512-PWoF9Keq6laYdIRwwCdhTPl60xRqAloYNMQLiyUnG42VjT53oW07BXIRM+NK7eQjzXjAk2gUvX9caRxlnF9TAA==", + "license": "MIT", + "dependencies": { + "punycode": "2.1.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/inquirer": { "version": "12.6.0", "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.6.0.tgz", @@ -831,6 +1034,18 @@ "ws": "*" } }, + "node_modules/js-crc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/js-crc/-/js-crc-0.2.0.tgz", + "integrity": "sha512-8DdCSAOACpF8WDAjyDFBC2rj8OS4HUP9mNZBDfl8jCiPCnJG+2bkuycalxwZh6heFy6PrMvoWTp47lp6gzT65A==", + "license": "MIT" + }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", + "license": "MIT" + }, "node_modules/js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -838,6 +1053,12 @@ "dev": true, "license": "MIT" }, + "node_modules/js-sha512": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha512/-/js-sha512-0.8.0.tgz", + "integrity": "sha512-PWsmefG6Jkodqt+ePTvBZCSMFgN7Clckjd0O7su3I0+BW2QWUTJNzjktHsztGLhncP2h8mcF9V9Y2Ha59pAViQ==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", @@ -908,6 +1129,12 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/nano-base32": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nano-base32/-/nano-base32-1.0.1.tgz", + "integrity": "sha512-sxEtoTqAPdjWVGv71Q17koMFGsOMSiHsIFEvzOM7cNp8BXB4AnEwmDabm5dorusJf/v1z7QxaZYxUorU9RKaAw==", + "license": "MIT" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -1034,6 +1261,29 @@ } } }, + "node_modules/punycode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.0.tgz", + "integrity": "sha512-Yxz2kRwT90aPiWEMHVYnEf4+rhwF1tBmmZ4KepCP+Wkium9JxtWnUm1nqGwpiAHr/tnTSeHqr3wb++jgSkXjhA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -1050,6 +1300,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "license": "MIT", + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "node_modules/ripemd160-min": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/ripemd160-min/-/ripemd160-min-0.0.6.tgz", + "integrity": "sha512-+GcJgQivhs6S9qvLogusiTcS9kQUfgR75whKuy5jIhuiOfQuJ8fjqxV6EGD5duH1Y/FawFUMtMhyeq3Fbnib8A==", + "engines": { + "node": ">=8" + } + }, "node_modules/run-async": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", @@ -1068,6 +1336,26 @@ "tslib": "^2.1.0" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -1084,6 +1372,15 @@ "semver": "bin/semver" } }, + "node_modules/sha3": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/sha3/-/sha3-2.1.4.tgz", + "integrity": "sha512-S8cNxbyb0UGUM2VhRD4Poe5N58gJnJsLJ5vC7FYWGUmGhcsj4++WaIOBFVDxlG0W3To6xBuiRh+i0Qp2oNCOtg==", + "license": "MIT", + "dependencies": { + "buffer": "6.0.3" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -1140,6 +1437,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1225,6 +1531,12 @@ "node": ">= 10.0.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/viem": { "version": "2.28.2", "resolved": "https://registry.npmjs.org/viem/-/viem-2.28.2.tgz", diff --git a/package.json b/package.json index f3b42c8..cbc64c2 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ }, "dependencies": { "@openzeppelin/contracts": "^5.0.2", + "@rsksmart/rns-resolver.js": "^1.1.0", "@rsksmart/rsk-precompiled-abis": "^6.0.0-ARROWHEAD", "@types/fs-extra": "^11.0.4", "@types/zxcvbn": "^4.4.5", diff --git a/src/commands/balance.ts b/src/commands/balance.ts index f504b34..47d16fd 100644 --- a/src/commands/balance.ts +++ b/src/commands/balance.ts @@ -21,6 +21,7 @@ import { WalletData } from "../utils/types.js"; type BalanceCommandOptions = { testnet: boolean; walletName?: string; + address?: Address; isExternal?: boolean; token?: string; customTokenAddress?: Address; @@ -95,45 +96,51 @@ export async function balanceCommand(params: BalanceCommandOptions): Promise { @@ -228,7 +273,7 @@ async function getBatchData(params: BatchTransferCommandOptions): Promise ({ - to: validateAddress(tx.to), + to: tx.to, value: tx.value, })); return batchData; diff --git a/src/commands/resolve.ts b/src/commands/resolve.ts new file mode 100644 index 0000000..11775ae --- /dev/null +++ b/src/commands/resolve.ts @@ -0,0 +1,178 @@ +import chalk from "chalk"; +import ora from "ora"; +import { Address } from "viem"; +import { resolveRNSToAddress, resolveAddressToRNS } from "../utils/rnsHelper.js"; +import { validateAndFormatAddressRSK, toEip1191ChecksumAddress } from "../utils/index.js"; + +type ResolveCommandOptions = { + name: string; + testnet: boolean; + reverse: boolean; + isExternal?: boolean; +}; + +type ResolveResult = { + success: boolean; + data?: { + name?: string; + address?: string; + network: string; + }; + error?: string; +}; + +function logMessage( + params: ResolveCommandOptions, + message: string, + color: any = chalk.white +) { + if (!params.isExternal) { + console.log(color(message)); + } +} + +function logInfo(params: ResolveCommandOptions, message: string) { + logMessage(params, message, chalk.blue); +} + +function startSpinner( + params: ResolveCommandOptions, + spinner: any, + message: string +) { + if (!params.isExternal) { + spinner.start(message); + } +} + +function stopSpinner(params: ResolveCommandOptions, spinner: any) { + if (!params.isExternal) { + spinner.stop(); + } +} + +function succeedSpinner( + params: ResolveCommandOptions, + spinner: any, + message: string +) { + if (!params.isExternal) { + spinner.succeed(message); + } +} + +function failSpinner( + params: ResolveCommandOptions, + spinner: any, + message: string +) { + if (!params.isExternal) { + spinner.fail(message); + } +} + +export async function resolveCommand( + params: ResolveCommandOptions = { name: "", testnet: false, reverse: false } +): Promise { + const spinner = ora(); + + try { + if (params.reverse) { + const formattedAddress = validateAndFormatAddressRSK(params.name, params.testnet); + if (!formattedAddress) { + const errorMessage = "Invalid address format for reverse lookup"; + failSpinner(params, spinner, chalk.red(`❌ ${errorMessage}`)); + return params.isExternal ? { success: false, error: errorMessage } : undefined; + } + + startSpinner(params, spinner, chalk.white("🔍 Looking up name for address...")); + const resolverName = await resolveAddressToRNS({ + address: formattedAddress as `0x${string}`, + testnet: params.testnet, + isExternal: params.isExternal + }); + + stopSpinner(params, spinner); + + if (resolverName) { + succeedSpinner(params, spinner, chalk.green("✅ Name found successfully")); + const displayAddress = toEip1191ChecksumAddress(formattedAddress as string, params.testnet) as Address; + logMessage(params, chalk.white(`📄 Address:`) + " " + chalk.green(displayAddress)); + logMessage(params, chalk.white(`🏷️ Name:`) + " " + chalk.green(resolverName)); + + if (params.isExternal) { + return { + success: true, + data: { + address: displayAddress, + name: resolverName, + network: params.testnet ? "Rootstock Testnet" : "Rootstock Mainnet" + } + }; + } + } else { + failSpinner(params, spinner, chalk.yellow("⚠️ No name found for this address")); + + if (params.isExternal) { + return { + success: false, + error: "No name found for this address" + }; + } + } + } else { + let domainName = params.name; + if (!domainName.endsWith(".rsk")) { + domainName = domainName + ".rsk"; + } + + startSpinner(params, spinner, chalk.white(`🔍 Resolving ${domainName}...`)); + + const resolvedAddress = await resolveRNSToAddress({ + name: domainName, + testnet: params.testnet, + isExternal: params.isExternal + }); + + stopSpinner(params, spinner); + + if (resolvedAddress) { + succeedSpinner(params, spinner, chalk.green("✅ Domain resolved successfully")); + logMessage(params, chalk.white(`🏷️ Domain:`) + " " + chalk.green(domainName)); + logMessage(params, chalk.white(`📄 Address:`) + " " + chalk.green(resolvedAddress)); + logMessage(params, chalk.white(`🌐 Network:`) + " " + chalk.green(params.testnet ? "Rootstock Testnet" : "Rootstock Mainnet")); + + if (params.isExternal) { + return { + success: true, + data: { + name: domainName, + address: resolvedAddress, + network: params.testnet ? "Rootstock Testnet" : "Rootstock Mainnet" + } + }; + } + } else { + failSpinner(params, spinner, chalk.yellow(`⚠️ No address found for ${domainName}`)); + logInfo(params, "💡 This domain may not be registered or has no address configured"); + + if (params.isExternal) { + return { + success: false, + error: `No address found for ${domainName}` + }; + } + } + } + } catch (error) { + stopSpinner(params, spinner); + const errorMessage = error instanceof Error ? error.message : "An unknown error occurred"; + + if (params.isExternal) { + return { + success: false, + error: errorMessage + }; + } + } +} diff --git a/src/commands/transfer.ts b/src/commands/transfer.ts index 9cabdd2..28a2a19 100644 --- a/src/commands/transfer.ts +++ b/src/commands/transfer.ts @@ -182,7 +182,6 @@ export async function transferCommand( logInfo(params, `🔑 Wallet account: ${account.address}`); if (params.tokenAddress) { - // Handle ERC20 token transfer const isERC20 = await isERC20Contract(publicClient, params.tokenAddress); if (!isERC20) { const errorMessage = "The provided address is not a valid ERC20 token contract."; @@ -193,7 +192,6 @@ export async function transferCommand( }; } - // Get token information const tokenName = await publicClient.readContract({ address: params.tokenAddress, abi: [{ @@ -218,7 +216,6 @@ export async function transferCommand( functionName: "symbol" }); - // Display token and transfer information logInfo(params, `📄 Token Information:`); logInfo(params, ` Name: ${tokenName}`); logInfo(params, ` Symbol: ${tokenSymbol}`); @@ -226,7 +223,6 @@ export async function transferCommand( logInfo(params, `🎯 To Address: ${params.toAddress}`); logInfo(params, `💵 Amount to Transfer: ${params.value} ${tokenSymbol}`); - // Check balance and proceed with transfer const { balance } = await getTokenInfo(publicClient, params.tokenAddress, walletAddress); const formattedBalance = Number(balance) / 10 ** 18; @@ -301,7 +297,6 @@ export async function transferCommand( }; } } else { - // Handle RBTC transfer const balance = await publicClient.getBalance({ address: walletAddress }); const rbtcBalance = Number(balance) / 10 ** 18; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 36e03c6..9740d2a 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -2,6 +2,8 @@ import path from "path"; export const walletFilePath = path.join(process.cwd(), "rootstock-wallet.json"); +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + export const METHOD_TYPES = { read: "read", write: "write", diff --git a/src/utils/index.ts b/src/utils/index.ts index c42e8b3..4872441 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,4 @@ -import { Address, isAddress, PublicClient } from "viem"; +import { Address, isAddress, PublicClient, keccak256, stringToHex } from "viem"; import chalk from "chalk"; import fs from "fs"; import { @@ -22,6 +22,71 @@ export function validateAndFormatAddress(address: string): Address | undefined { return formattedAddress as Address; } +export function getRootstockChainId(testnet: boolean): 30 | 31 { + return testnet ? 31 : 30; +} + +export function validateAndFormatAddressRSK( + address: string, + testnet: boolean = false +): Address | undefined { + if (!address) return undefined; + const lower = `0x${address.replace(/^0x/, "").toLowerCase()}`; + const hex = lower.replace(/^0x/, ""); + if (!/^([a-f0-9]{40})$/.test(hex)) return undefined; + + const input = address.startsWith("0x") ? address : `0x${address}`; + const hasUpper = /[A-F]/.test(input); + const hasLower = /[a-f]/.test(input); + const isMixedCase = hasUpper && hasLower; + + if (isMixedCase) { + const eip1191 = toEip1191ChecksumAddress(lower, testnet); + const eip55 = toEip55ChecksumAddress(lower); + if (input !== eip1191 && input !== eip55) { + return undefined; + } + } + + // Accept all-lowercase or valid checksummed (EIP-55 or EIP-1191). Return lowercase for internal use. + return lower as Address; +} + +export function toEip1191ChecksumAddress( + address: string, + testnet: boolean = false +): Address { + const chainId = getRootstockChainId(testnet); + const clean = address.replace(/^0x/, ""); + if (!/^([a-fA-F0-9]{40})$/.test(clean)) { + return (`0x${clean.toLowerCase()}`) as Address; + } + const lower = clean.toLowerCase(); + const input = `${chainId}0x${lower}`; + const hash = keccak256(stringToHex(input)).slice(2); + let checksummed = ""; + for (let i = 0; i < lower.length; i++) { + const h = parseInt(hash[i], 16); + checksummed += h >= 8 ? lower[i].toUpperCase() : lower[i]; + } + return (`0x${checksummed}`) as Address; +} + +export function toEip55ChecksumAddress(address: string): Address { + const clean = address.replace(/^0x/, ""); + if (!/^([a-fA-F0-9]{40})$/.test(clean)) { + return (`0x${clean.toLowerCase()}`) as Address; + } + const lower = clean.toLowerCase(); + const hash = keccak256(stringToHex(lower)).slice(2); + let checksummed = ""; + for (let i = 0; i < lower.length; i++) { + const h = parseInt(hash[i], 16); + checksummed += h >= 8 ? lower[i].toUpperCase() : lower[i]; + } + return (`0x${checksummed}`) as Address; +} + export async function isValidContract( client: PublicClient, address: Address diff --git a/src/utils/rnsHelper.ts b/src/utils/rnsHelper.ts new file mode 100644 index 0000000..e3ff084 --- /dev/null +++ b/src/utils/rnsHelper.ts @@ -0,0 +1,149 @@ +import { Address, isAddress } from "viem"; +import chalk from "chalk"; +import { ZERO_ADDRESS } from "./constants.js"; +import { validateAndFormatAddressRSK } from "./index.js"; + +type ResolveRNSOptions = { + name: string; + testnet?: boolean; + isExternal?: boolean; +}; + +type ResolveAddressOptions = { + address: Address; + testnet?: boolean; + isExternal?: boolean; +}; + +type ResolveToAddressOptions = { + input: string; + testnet?: boolean; + isExternal?: boolean; +}; + +function logMessage( + isExternal: boolean | undefined, + message: string, + color: typeof chalk.white = chalk.white +) { + if (!isExternal) { + console.log(color(message)); + } +} + +function logError(isExternal: boolean | undefined, message: string) { + logMessage(isExternal, `❌ ${message}`, chalk.red); +} + +function logWarning(isExternal: boolean | undefined, message: string) { + logMessage(isExternal, message, chalk.yellow); +} + +function logSuccess(isExternal: boolean | undefined, message: string) { + logMessage(isExternal, message, chalk.green); +} + +export function isRNSDomain(input: string): boolean { + if (input.endsWith(".rsk")) { + const domainName = input.replace(".rsk", ""); + return domainName.length >= 5 && /^[a-z0-9-]+$/i.test(domainName); + } + + if (!input.startsWith("0x") && !input.includes(".")) { + return input.length >= 5 && /^[a-z0-9-]+$/i.test(input); + } + + return false; +} + +async function getResolver() { + const RNSResolverModule = await import("@rsksmart/rns-resolver.js"); + return (RNSResolverModule as any).default.default || (RNSResolverModule as any).default; +} + +export async function resolveRNSToAddress( + params: ResolveRNSOptions +): Promise
{ + try { + let name = params.name; + if (!name.endsWith(".rsk")) { + name = name + ".rsk"; + } + + const Resolver = await getResolver(); + const resolver = params.testnet + ? Resolver.forRskTestnet({}) + : Resolver.forRskMainnet({}); + + const resolvedAddress = await resolver.addr(name) as Address; + + if (!resolvedAddress || resolvedAddress === ZERO_ADDRESS) { + logWarning(params.isExternal, `⚠️ No address found for ${name}`); + return null; + } + + const formatted = validateAndFormatAddressRSK(resolvedAddress, !!params.testnet); + if (!formatted) { + logError(params.isExternal, `Failed to validate resolved address for ${name}`); + return null; + } + logSuccess(params.isExternal, `✅ Resolved ${name} to ${formatted}`); + return formatted; + } catch (error) { + logError(params.isExternal, `Failed to resolve RNS name: ${params.name}`); + if (error instanceof Error && !params.isExternal) { + logWarning(params.isExternal, error.message); + } + return null; + } +} + +export async function resolveAddressToRNS( + params: ResolveAddressOptions +): Promise { + try { + const Resolver = await getResolver(); + const resolver = params.testnet + ? Resolver.forRskTestnet({}) + : Resolver.forRskMainnet({}); + + const resolverName = await resolver.reverse(params.address) as string; + + if (resolverName && resolverName !== "") { + logSuccess(params.isExternal, `✅ Resolved ${params.address} to ${resolverName}`); + return resolverName; + } + + return null; + } catch (error) { + logError(params.isExternal, `Failed to reverse resolve address: ${params.address}`); + if (error instanceof Error && !params.isExternal) { + logWarning(params.isExternal, error.message); + } + return null; + } +} + +export async function resolveToAddress( + params: ResolveToAddressOptions +): Promise
{ + if (params.input.startsWith("0x") && params.input.length === 42) { + if (isAddress(params.input)) { + return params.input as Address; + } else { + logError(params.isExternal, "Invalid address format"); + return null; + } + } + + if (isRNSDomain(params.input)) { + return await resolveRNSToAddress({ + name: params.input, + testnet: params.testnet, + isExternal: params.isExternal + }); + } + + logError(params.isExternal, "Input is neither a valid address nor an RNS domain"); + return null; +} diff --git a/src/utils/types.ts b/src/utils/types.ts index 7386b2c..6b386d0 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -14,8 +14,8 @@ export type WalletItem = { }; export type FileTx = { - to: Address; - value: bigint; + to: Address | string; + value: number; }; export type TxResult = {