diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000000..2611cd5854 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,220 @@ +Command-Line Interface (CLI) +============================ + +The command-line interface provides several simple tools to manage +and debug Ethereum-related tasks using the ethers.js library. + +**To install:** + +``` +/home/ricmoo> npm install -g @ethersproject/cli +``` + +----- + +Sandbox Utility +=============== + +The sandbox utility run on its own will run a REPL environment similar +to running `node`, with many features from the ethers.js library +already imported and permits loading accounts and setting up a provider. + +It also provides a simple interface to common tasks, such as sweeping +accounts, signing messages and compiling Solidity. + +**Example:** Create and fund testnet account + +``` +/home/ricmoo> ethers init ropsten.json +Creating a new JSON Wallet - ropsten.json +Keep this password and file SAFE!! If lost or forgotten +it CANNOT be recovered, by ANYone, EVER. +Choose a password: **** +Confirm password: **** +Encrypting... 100% +New account address: 0xe923a7f82860C30442a1A541C14bE4251bd71A34 +Saved: ropsten.json +/home/ricmoo> ethers --wait --network ropsten fund 0xe923a7f82860C30442a1A541C14bE4251bd71A34 +Transaction Hash: 0x457c1d8b58170c73a02afa2816e877de41d6337a483d4af9cbd674d2b478473d +/home/ethers> +``` + +**Example:** Simple evaluations + +``` +/home/ricmoo> ethers eval 'namehash("ricmoose.eth")' +0xb52c4744695ed3be701ccef35d5901de3aaf7294245966ef16617c30aab7b626 + +/home/ricmoo> ethers eval 'id("Hello...")' +0x9cd41c139084dafa62261ce045f504e3c697fa303c87a78b241a9f8ae65bae88 + +/home/ricmoo> ethers --network ropsten eval '(new Contract(provider.network.ensAddress, [ "function owner(bytes32) view returns (address)" ], provider)).owner(namehash("eth"))' +0x227Fcb6Ddf14880413EF4f1A3dF2Bbb32bcb29d7 +``` + +**Example:** REPL + +``` +/home/ricmoo> ethers --network ropsten --account mnemonic.txt +network: ropsten (chainId: 3) +ropsten> provider.getGasPrice() +BigNumber { _hex: '0xb2d05e00', _isBigNumber: true } +ropsten> accounts[0].signMessage("Hello..."); +Message: + Message: "Hello..." + Message (hex): 0x48656c6c6f2e2e2e +Sign Message? (y/N/a) yy +Signature + Flat: 0x37e9add966fe86d50bc5d816f9cb8213107d428551e64f118bc087fe1e4031f61685c7123d14f47e673ada87049cbbecab0c4e9ef5e8ded073e0be9980d14e761b + r: 0x37e9add966fe86d50bc5d816f9cb8213107d428551e64f118bc087fe1e4031f6 + s: 0x1685c7123d14f47e673ada87049cbbecab0c4e9ef5e8ded073e0be9980d14e76 + vs: 0x1685c7123d14f47e673ada87049cbbecab0c4e9ef5e8ded073e0be9980d14e76 + v: 27 + recid: 0 +'0x37e9add966fe86d50bc5d816f9cb8213107d428551e64f118bc087fe1e4031f61685c7123d14f47e673ada87049cbbecab0c4e9ef5e8ded073e0be9980d14e761b' +``` + +Help (--help) +------------- + +``` +Usage: + ethers [ COMMAND ] [ ARGS ] [ OPTIONS ] + +COMMANDS (default: sandbox) + sandbox Run a REPL VM environment with ethers + init FILENAME Create a new JSON wallet + [ --force ] Overwrite any existing files + fund TARGET Fund TARGET with testnet ether + info [ TARGET ... ] Dump info for accounts, addresses and ENS names + send TARGET ETHER Send ETHER ether to TARGET form accounts[0] + [ --allow-zero ] Allow sending to the address zero + [ --data DATA ] Include data in the transaction + sweep TARGET Send all ether from accounts[0] to TARGET + sign-message MESSAGE Sign a MESSAGE with accounts[0] + [ --hex ] The message content is hex encoded + eval CODE Run CODE in a VM with ethers + run FILENAME Run FILENAME in a VM with ethers + wait HASH Wait for a transaction HASH to be mined + compile FILENAME Compiles a Solidity contract + [ --no-optimize ] Do not optimize the compiled output + [ --warnings ] Error on any warning + deploy FILENAME Compile and deploy a Solidity contract + [ --no-optimize ] Do not optimize the compiled output + [ --contract NAME ] Specify the contract to deploy + +ACCOUNT OPTIONS + --account FILENAME Load from a file (JSON, RAW or mnemonic) + --account RAW_KEY Use a private key (insecure *) + --account 'MNEMONIC' Use a mnemonic (insecure *) + --account - Use secure entry for a raw key or mnemonic + --account-void ADDRESS Use an address as a void signer + --account-void ENS_NAME Add the resolved address as a void signer + --account-rpc ADDRESS Add the address from a JSON-RPC provider + --account-rpc INDEX Add the index from a JSON-RPC provider + --mnemonic-password Prompt for a password for mnemonics + --xxx-mnemonic-password Prompt for a (experimental) hard password + +PROVIDER OPTIONS (default: all + homestead) + --alchemy Include Alchemy + --etherscan Include Etherscan + --infura Include INFURA + --nodesmith Include nodesmith + --rpc URL Include a custom JSON-RPC + --offline Dump signed transactions (no send) + --network NETWORK Network to connect to (default: homestead) + +TRANSACTION OPTIONS (default: query network) + --gasPrice GWEI Default gas price for transactions(in wei) + --gasLimit GAS Default gas limit for transactions + --nonce NONCE Initial nonce for the first transaction + --yes Always accept Siging and Sending + +OTHER OPTIONS + --wait Wait until transactions are mined + --debug Show stack traces for errors + --help Show this usage and exit + --version Show this version and exit + +(*) By including mnemonics or private keys on the command line they are + possibly readable by other users on your system and may get stored in + your bash history file. This is NOT recommended. +``` + +----- + +Ethereum Naming Service (ENS) +============================= + +These tools help manage ENS names. + +Help (--help) +------------- + +``` +Usage: + ethers-ens COMMAND [ ARGS ] [ OPTIONS ] + +COMMANDS + lookup [ NAME | ADDRESS [ ... ] ] + Lookup a name or address + commit NAME Submit a pre-commitment + [ --duration DAYS ] Register duration (default: 365 days) + [ --salt SALT ] SALT to blind the commit with + [ --secret SECRET ] Use id(SECRET) as the salt + [ --owner OWNER ] The target owner (default: current account) + reveal NAME Reveal a previous pre-commitment + [ --duration DAYS ] Register duration (default: 365 days) + [ --salt SALT ] SALT to blind the commit with + [ --secret SECRET ] Use id(SECRET) as the salt + [ --owner OWNER ] The target owner (default: current account) + set-controller NAME Set the controller (default: current account) + [ --address ADDRESS ] Specify another address + set-subnode NAME Set a subnode owner (default: current account) + [ --address ADDRESS ] Specify another address + set-resolver NAME Set the resolver (default: resolver.eth) + [ --address ADDRESS ] Specify another address + set-addr NAME Set the addr record (default: current account) + [ --address ADDRESS ] Specify another address + set-text NAME KEY VALUE Set a text record + set-email NAME EMAIL Set the email text record + set-website NAME URL Set the website text record + set-content NAME HASH Set the IPFS Content Hash + migrate-registrar NAME Migrate from the Legacy to the Permanent Registrar + transfer NAME NEW_OWNER Transfer registrant ownership + reclaim NAME Reset the controller by the registrant + [ --address ADDRESS ] Specify another address +``` + +See above for the `ACCOUNT`, `PROVIDER`, `TRANSACTION`, and `OTHER` +options. + +----- + +TypeScript Utility +================== + +The TypeScript utility compiles Solidity contracts into a single file +with each contract sub-classing Contract, with all typing information +added. + +Help (--help) +------------- + +``` +Usage: + ethers-ts FILENAME [ ... ] [ OPTIONS ] + +OPTIONS + --output FILENAME Write the output to FILENAME (default: stdout) + --force Overwrite files if they already exist + --no-optimize Do not run the solc optimizer + --no-bytecode Do not include bytecode and Factory methods +``` + +----- + +License +======= + +MIT License diff --git a/packages/cli/src.ts/bin/ethers-ens.ts b/packages/cli/src.ts/bin/ethers-ens.ts index ed3e10809d..6fff95ca8c 100644 --- a/packages/cli/src.ts/bin/ethers-ens.ts +++ b/packages/cli/src.ts/bin/ethers-ens.ts @@ -21,9 +21,12 @@ const ensAbi = [ const States = Object.freeze([ "Open", "Auction", "Owned", "Forbidden", "Reveal", "NotAvailable" ]); +const deedAbi = [ + "function owner() view returns (address)" +]; + const ethLegacyRegistrarAbi = [ "function entries(bytes32 _hash) view returns (uint8 state, address owner, uint registrationDate, uint value, uint highestBid)", - "function state(bytes32 _hash) public view returns (uint8)", "function transferRegistrars(bytes32 _hash) @500000", ]; @@ -31,13 +34,15 @@ const ethControllerAbi = [ "function rentPrice(string memory name, uint duration) view public returns(uint)", "function available(string memory label) public view returns(bool)", "function makeCommitment(string memory name, address owner, bytes32 secret) pure public returns(bytes32)", - "function commit(bytes32 commitment) public", + "function commit(bytes32 commitment) public @500000", "function register(string calldata name, address owner, uint duration, bytes32 secret) payable @500000", "function renew(string calldata name, uint duration) payable @500000", ]; const ethRegistrarAbi = [ - "function transferFrom(address from, address to, uint256 tokenId)" + "function ownerOf(uint256 tokenId) view returns (address)", + "function reclaim(uint256 id, address owner) @500000", + "function transferFrom(address from, address to, uint256 tokenId) @500000" ]; const resolverAbi = [ @@ -50,9 +55,9 @@ const resolverAbi = [ "function setContenthash(bytes32 nodehash, bytes contenthash) @500000", ]; -const InterfaceID_ERC721 = "0x6ccb2df4"; -const InterfaceID_Controller = "0x018fac06"; -const InterfaceID_Legacy = "0x7ba18ba1"; +//const InterfaceID_ERC721 = "0x6ccb2df4"; +const InterfaceID_Controller = "0x018fac06"; +const InterfaceID_Legacy = "0x7ba18ba1"; /* @@ -108,7 +113,8 @@ abstract class EnsPlugin extends Plugin { } async getEthRegistrar(): Promise { - let address = await this.getEthInterfaceAddress(InterfaceID_ERC721); + //let address = await this.getEthInterfaceAddress(InterfaceID_ERC721); + let address = await this.getEns().owner(ethers.utils.namehash("eth")); return new ethers.Contract(address, ethRegistrarAbi, this.accounts[0] || this.provider); } } @@ -135,74 +141,90 @@ class LookupPlugin extends EnsPlugin { let ens = this.getEns(); + let controller = await this.getEthController(); + let registrar = await this.getEthRegistrar(); + let legacyRegistrar = await this.getEthLegacyRegistrar(); + for (let i = 0; i < this.names.length; i++) { let name = this.names[i]; let nodehash = ethers.utils.namehash(name); - let details: any = { - Owner: ens.owner(nodehash), - Resolver: ens.resolver(nodehash) + let details: { [ key: string]: string } = { + Nodehash: nodehash }; + let owner = await ens.owner(nodehash); + let resolverAddress: string = null; + if (owner === ethers.constants.AddressZero) { + owner = null; + } else { + details.Controller = owner; + details.Resolver = await ens.resolver(nodehash).then((address: string) => { + if (address === ethers.constants.AddressZero) { + return "(not configured)"; + } + resolverAddress = address; + return address; + }); + } + let comps = name.split("."); if (comps.length === 2 && comps[1] === "eth") { - let labelhash = ethers.utils.id(comps[0].toLowerCase()); // @TODO: nameprep - - let available = this.getEthController().then((ethController) => { - return ethController.available(comps[0]); - }); - details.Available = available; - - let legacyRegistrarPromise = this.getEthLegacyRegistrar(); - - details._Registrar = Promise.all([ - available, - legacyRegistrarPromise.then((legacyRegistrar) => { - return legacyRegistrar.state(labelhash); - }) - ]).then((results) => { - let available = results[0]; - let state = States[results[1]]; - if (!available && state === "Owned") { - return legacyRegistrarPromise.then((legacyRegistrar) => { - return legacyRegistrar.entries(labelhash).then((entries: any) => { - return { - Registrar: "Legacy", - "Deed Value": (ethers.utils.formatEther(entries.value) + " ether"), - "Highest Bid": (ethers.utils.formatEther(entries.highestBid) + " ether"), - } - }); - }); + details.Labelhash = ethers.utils.id(comps[0].toLowerCase()); // @TODO: nameprep + + details.Available = await controller.available(comps[0]); + + if (!details.Available) { + try { + let ownerOf = await registrar.ownerOf(details.Labelhash); + if (ownerOf !== ethers.constants.AddressZero) { + details.Registrant = ownerOf; + details.Registrar = "Permanent"; + } + } catch (error) { + let entry = await legacyRegistrar.entries(details.Labelhash); + let deed = new ethers.Contract(entry.owner, deedAbi, this.provider); + + details.Registrant = await deed.owner(); + details.Registrar = "Legacy"; + details["Deed Value"] = (ethers.utils.formatEther(entry.value) + " ether"); + details["Highest Bid"] = (ethers.utils.formatEther(entry.highestBid) + " ether"); } - return { Registrar: "Permanent" }; - }); + } } - details = await ethers.utils.resolveProperties(details); + if (resolverAddress) { + let resolver = new ethers.Contract(resolverAddress, resolverAbi, this.provider); + details["Address"] = await resolver.addr(nodehash); - if (details.Resolver !== ethers.constants.AddressZero) { - let resolver = new ethers.Contract(details.Resolver, resolverAbi, this.provider); - details["Address"] = resolver.addr(nodehash); - details["E-mail"] = resolver.text(nodehash, "email").catch((error: any) => ("")); - details["Website"] = resolver.text(nodehash, "website").catch((error: any) => ("")); - details["Content Hash"] = resolver.contenthash(nodehash).then((hash: string) => { - if (hash === "0x") { return "0x"; } + let email = await resolver.text(nodehash, "email").catch((error: any) => ("")); + if (email) { details["E-mail"] = email; } + + let website = await resolver.text(nodehash, "website").catch((error: any) => ("")); + if (website) { details["Website"] = website; } + + let content = await resolver.contenthash(nodehash).then((hash: string) => { + if (hash === "0x") { return ""; } if (hash.substring(0, 10) === "0xe3010170" && ethers.utils.isHexString(hash, 38)) { return Base58.encode(ethers.utils.hexDataSlice(hash, 4)) + " (IPFS)"; } return hash + " (unknown format)"; }, (error: any) => ("")); + if (content) { details["Content Hash"] = content; } } - details = await ethers.utils.resolveProperties(details); - - for (let key in details._Registrar) { - details[key] = details._Registrar[key]; + let ordered: { [ key: string]: string } = { }; + "Nodehash,Labelhash,Available,Registrant,Controller,Resolver,Address,Registrar,Deed Value,Highest Bid,E-mail,Website,Content Hash".split(",").forEach((key) => { + if (!details[key]) { return; } + ordered[key] = details[key]; + }); + for (let key in details) { + if (ordered[key]) { continue; } + ordered[key] = details[key]; } - delete details._Registrar; - this.dump("Name: " + this.names[i], details); + this.dump("Name: " + this.names[i], ordered); } } } @@ -335,7 +357,7 @@ class CommitPlugin extends ControllerPlugin { static getHelp(): Help { return { name: "commit NAME", - help: "Commit to NAME" + help: "Submit a pre-commitment" } } @@ -364,8 +386,8 @@ class RevealPlugin extends ControllerPlugin { static getHelp(): Help { return { - name: "reveal LABEL", - help: "Reveal a previously committed name" + name: "reveal NAME", + help: "Reveal a previous pre-commitment" } } @@ -438,7 +460,7 @@ abstract class AddressAccountPlugin extends AccountPlugin { return [ { name: "[ --address ADDRESS ]", - help: "Override the address" + help: "Specify another address" } ]; } @@ -459,12 +481,12 @@ abstract class AddressAccountPlugin extends AccountPlugin { } } -class SetOwnerPlugin extends AddressAccountPlugin { +class SetControllerPlugin extends AddressAccountPlugin { static getHelp(): Help { return { - name: "set-owner NAME", - help: "Set the owner of NAME (default: current account)" + name: "set-controller NAME", + help: "Set the controller (default: current account)" } } @@ -474,7 +496,7 @@ class SetOwnerPlugin extends AddressAccountPlugin { this.getEns().setOwner(this.nodehash, this.address); } } -cli.addPlugin("set-owner", SetOwnerPlugin); +cli.addPlugin("set-controller", SetControllerPlugin); class SetSubnodePlugin extends AddressAccountPlugin { label: string; @@ -483,7 +505,7 @@ class SetSubnodePlugin extends AddressAccountPlugin { static getHelp(): Help { return { name: "set-subnode NAME", - help: "Set the subnode owner" + help: "Set a subnode owner (default: current account)" } } @@ -513,7 +535,7 @@ class SetResolverPlugin extends AddressAccountPlugin { static getHelp(): Help { return { name: "set-resolver NAME", - help: "Set the resolver for NAME (default: resolver.eth)" + help: "Set the resolver (default: resolver.eth)" } } @@ -586,7 +608,7 @@ class SetTextPlugin extends TextAccountPlugin { static getHelp(): Help { return { name: "set-text NAME KEY VALUE", - help: "Set the KEY text record to VALUE" + help: "Set a text record" } } @@ -602,7 +624,7 @@ class SetEmailPlugin extends TextAccountPlugin { static getHelp(): Help { return { name: "set-email NAME EMAIL", - help: "Set the email text record to EMAIL" + help: "Set the email text record" } } @@ -618,7 +640,7 @@ class SetWebsitePlugin extends TextAccountPlugin { static getHelp(): Help { return { name: "set-website NAME URL", - help: "Set the website text record to URL" + help: "Set the website text record" } } @@ -636,7 +658,7 @@ class SetContentPlugin extends AccountPlugin { static getHelp(): Help { return { name: "set-content NAME HASH", - help: "Set the IPFS HASH for NAME" + help: "Set the IPFS Content Hash" } } @@ -669,40 +691,54 @@ cli.addPlugin("set-content", SetContentPlugin); class MigrateRegistrarPlugin extends AccountPlugin { readonly label: string; + readonly deedValue: ethers.BigNumber; + readonly highestBid: ethers.BigNumber; static getHelp(): Help { return { name: "migrate-registrar NAME", - help: "Migrates NAME from the Legacy to Permanent Registrar" + help: "Migrate from the Legacy to the Permanent Registrar" } } async prepareArgs(args: Array): Promise { await super.prepareArgs(args); + // Only Top-Level names can be migrated let comps = this.name.split("."); if (comps.length !== 2 || comps[1] !== "eth") { this.throwError("Not a top-level .eth name"); } - - // @TODO: Should probably check that accounts[0].getAddress() matches - // the owner in the legacy registrar + await super._setValue("label", comps[0]); let ethLegacyRegistrar = await this.getEthLegacyRegistrar(); - let state = await ethLegacyRegistrar.state(ethers.utils.id(comps[0])); + let entry: any = await ethLegacyRegistrar.entries(ethers.utils.id(comps[0])); - if (States[state] !== "Owned") { + // Only owned names can be migrated + if (States[entry.state] !== "Owned") { this.throwError("Name not present in the Legacy registrar"); } - await super._setValue("label", comps[0]); + let deed = new ethers.Contract(entry.owner, deedAbi, this.provider); + let owner = await deed.owner(); + let address = await this.accounts[0].getAddress(); + + // Only the deed owner (registrant) may migrate a name + if (owner !== address) { + this.throwError("Only the registrant can migrate"); + } + + await super._setValue("deedValue", entry.value); + await super._setValue("highestBid", entry.highestBid); } async run(): Promise { await super.run(); this.dump("Migrate Registrar: " + this.name, { - Nodehash: this.nodehash + "Nodehash": this.nodehash, + "Highest Bid": (ethers.utils.formatEther(this.highestBid) + " ether"), + "Deed Value": (ethers.utils.formatEther(this.deedValue) + " ether"), }); let legacyRegistrar = await this.getEthLegacyRegistrar(); @@ -720,16 +756,16 @@ class TransferPlugin extends AccountPlugin { static getHelp(): Help { return { name: "transfer NAME NEW_OWNER", - help: "Transfers NAME to NEW_OWNER (permanent regstrar only)" + help: "Transfer registrant ownership" } } async _setValue(key: string, value: string): Promise { if (key === "new_owner") { let address = await this.getAddress(value); - await this._setValue(key, address); + await super._setValue(key, address); } else if (key === "name") { - let comps = this.name.split("."); + let comps = value.split("."); if (comps.length !== 2 || comps[1] !== "eth") { this.throwError("Not a top-level .eth name"); } @@ -754,12 +790,61 @@ class TransferPlugin extends AccountPlugin { } cli.addPlugin("transfer", TransferPlugin); +class ReclaimPlugin extends AddressAccountPlugin { + readonly label: string; + + static getHelp(): Help { + return { + name: "reclaim NAME", + help: "Reset the controller by the registrant" + } + } + + async _setValue(key: string, value: string): Promise { + if (key === "name") { + let comps = value.split("."); + if (comps.length !== 2 || comps[1] !== "eth") { + this.throwError("Not a top-level .eth name"); + } + + let account = await this.accounts[0].getAddress(); + + let registrar = await this.getEthRegistrar(); + let ownerOf: string = null; + try { + ownerOf = await registrar.ownerOf(ethers.utils.id(comps[0])); + } catch (error) { + this.throwError("Name not present in Permantent Registrar"); + } + + if (account !== ownerOf) { + this.throwError("Only the registrant can call reclaim"); + } + + await super._setValue("label", comps[0]); + } + await super._setValue(key, value); + } + + async run(): Promise { + await super.run(); + + this.dump("Reclaim: " + this.name, { + Nodehash: this.nodehash, + "Address": this.address, + }); + + let registrar = await this.getEthRegistrar(); + await registrar.reclaim(ethers.utils.id(this.label), this.address); + } +} +cli.addPlugin("reclaim", ReclaimPlugin); + /** * To Do: * register NAME --registrar * set-reverse NAME * renew NAME --duration DAYS - * reclaim NAME --address OWNER * * Done: * migrate-registrar NAME @@ -773,6 +858,7 @@ cli.addPlugin("transfer", TransferPlugin); * set-webstie NAME WEBSITE * set-text NAME KEY VALUE * set-content NAME HASH + * reclaim NAME --address OWNER */ cli.run(process.argv.slice(2))