diff --git a/README.md b/README.md index 98fe3e4..ee9877c 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ # etherdelta-go -This is an example of how to interact with Ethereum contracts from Go through RPC. +This is an example of how to interact with an Ethereum contract from Go through RPC. + +The example shows three things: +* how to generate Go language bindings given a contract's ABI definition, +* how to read data from the blockchain without spending gas, and +* how to change state on the blockchain by providing the correct private key and spending gas # The contract library -It uses [abigen](https://github.com/ethereum/go-ethereum/tree/v1.7.2/cmd/abigen) +We use [abigen](https://github.com/ethereum/go-ethereum/tree/v1.7.2/cmd/abigen) to generate Go language bindings given a contract's ABI definition. To regenerate the code [install abigen](https://github.com/ethereum/go-ethereum/tree/v1.7.2#building-the-source), put the ABI definition into a file (e.g., [for the EtherDelta contract](https://etherscan.io/address/0x8d12a197cb00d4747a1fe03395095ce2a5cc6819#code)) and run: @@ -22,28 +27,50 @@ The [example code](main.go) contains a working interaction with the Ethereum blo When using [EtherDelta](https://etherdelta.com/) (a decentralized exchange based on smart contracts) a user deposits funds from her own address into a pool that can be accessed by EtherDelta for executing trades. Later the user withdraws any funds from the pool back to her own address. Often there's a tiny amount left in the pool which is tedious to withdraw manually. +`etherdelta-go` to the rescue; the following command will withdraw any remaining deposited funds back to the owner for a given token. + +Build and install the binary: + ```console $ cd $GOPATH/src/github.com/linki/etherdelta-go $ glide install --strip-vendor -$ go run main.go --endpoint "https://mainnet.infura.io" \ - --token "0x27d...488" --owner "0x585...828C" --private-key "d4c...7b3" +$ go install ``` -`etherdelta-go` to the rescue; the above command will withdraw any remaining deposited funds back to the owner for a given token. It takes the following arguments: -* an RPC endpoint to interact with the Ethereum blockchain (you can run your own Ethereum node or connect to one provided by [Infura](https://infura.io/) free of charge) -* the address of the token you want to withdraw, e.g., [AirToken](https://etherscan.io/token/0x27dce1ec4d3f72c3e457cc50354f1f975ddef488) -* the address of the owner of the tokens and where the funds will be withdrawn to (this should be one of yours) -* the private key corresponding to the owner's address in order to sign the withdraw transaction (this proves that you are the owner of the above address) +Make sure `$GOPATH/bin` is in your `$PATH`, then run it like that: + +```console +$ etherdelta-go --token "0x27d...488" --keystore-file "UTC-...98c" --passphrase "my...pass" +Deposited tokens: 500000000 +Withdrawing tokens: 500000000 +Transaction hash: 0x1a6328...7ef38a -The example code shows two things: +$ # Wait for transaction to be mined, then run it again. -* how to read data from the blockchain without spending gas (e.g., printing the token balance for a particular address) -* how to change state in the blockchain by providing the correct private key and spending gas +$ etherdelta-go --token "0x27d...488" --keystore-file "UTC-...98c" --passphrase "my...pass" +Deposited tokens: 0 +``` -The example code uses one Gwei for the gas cost and 100,000 for the gas limit but you can configure that via the `gas-price` (in Gwei) and `gas-limit` flags. +It takes the following arguments: +* `token`: the address of the [ERC20](https://theethereum.wiki/w/index.php/ERC20_Token_Standard) [Token](https://etherscan.io/tokens) you want to withdraw, e.g., [AirToken](https://etherscan.io/token/0x27dce1ec4d3f72c3e457cc50354f1f975ddef488) +* `keystore-file`: the location of an Ethereum [Keystore file](https://theethereum.wiki/w/index.php/Accounts,_Addresses,_Public_And_Private_Keys,_And_Tokens#UTC_JSON_Keystore_File) which describes where the funds will be withdrawn to as well as contains the encrypted private key for that address, e.g., `~/Library/Ethereum/keystore/UTC--2017-11-...61d3f9` +* `passphrase`: the passphrase unlocking the `keystore-file`. (This proves that you are the owner of the Keystore file) If the deposited token balance is zero then no transaction will be issued. +There are some optional arguments as well: +* `endpoint`: an RPC endpoint to interact with the Ethereum blockchain. You can run your own Ethereum node or connect to one provided by [Infura](https://infura.io/) free of charge. Defaults to `https://mainnet.infura.io`. +* `contract`: The address of the EtherDelta contract to use. Since contracts are immutable each change to the contract requires a new contract to be deployed and results in a new contract address. Defaults to the most recent contract as of November 2017: [0x8d12A197cB00D4747a1fe03395095ce2A5CC6819](https://etherscan.io/address/0x8d12A197cB00D4747a1fe03395095ce2A5CC6819). +* `skip-withdraw`: doesn't send any withdraw transaction but merely shows the token balance. Note that `passphrase` must still be provided and correct in order to successfully process the Keystore file. Defaults to `false` (enable withdrawal). +* `gas-price`: The gas price in wei for the withdrawal transaction. Defaults to `1000000000` which is 1 Gwei. +* `gas-limit`: The maximum gas to use for the withdrawal transaction. Defaults to `100000`. During my testing for AirToken the final gas for successful transactions hovered around `50000`. + +Keeping the defaults for `gas-price` and `gas-limit` will result in a maximum transaction fee of `0.0001` ETH. + # Next steps It would be nice to extend the tool to iterate over all tokens that have some deposited funds left in EtherDelta's pool and send it back to the owner. + +# Disclaimer + +No warranty for any funds lost. See [LICENSE](LICENSE) for details. diff --git a/main.go b/main.go index edecda8..964ad78 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "io/ioutil" "log" "math/big" "os" @@ -9,8 +10,8 @@ import ( "github.com/alecthomas/kingpin" "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/accounts/keystore" "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" @@ -18,9 +19,10 @@ import ( ) const ( - version = "v0.1.0" + version = "v0.2.0" + defaultEndpoint = "https://mainnet.infura.io" defaultContractAddress = "0x8d12A197cB00D4747a1fe03395095ce2A5CC6819" - defaultGasPrice = "1" + defaultGasPrice = "1000000000" defaultGasLimit = "100000" ) @@ -28,65 +30,94 @@ var ( endpoint string contractAddress string tokenAddress string - ownerAddress string - ownerPrivateKey string + keyStorePath string + passphrase string + skipWithdraw bool gasPrice int64 gasLimit int64 ) func init() { - kingpin.Flag("endpoint", "Ethereum RPC endpoint").Required().StringVar(&endpoint) - kingpin.Flag("contract", "EtherDelta contract address").Default(defaultContractAddress).StringVar(&contractAddress) - kingpin.Flag("token", "The token's contract address").Required().StringVar(&tokenAddress) - kingpin.Flag("owner", "The owner's address").Required().StringVar(&ownerAddress) - kingpin.Flag("private-key", "The owner's address' private key").Required().StringVar(&ownerPrivateKey) - kingpin.Flag("gas-price", "The gas price in Gwei").Default(defaultGasPrice).Int64Var(&gasPrice) - kingpin.Flag("gas-limit", "The gas limit").Default(defaultGasLimit).Int64Var(&gasLimit) + kingpin.Flag("endpoint", "Ethereum RPC endpoint (optional, default: https://mainnet.infura.io)").Default(defaultEndpoint).StringVar(&endpoint) + kingpin.Flag("contract", "EtherDelta contract address (optional, default: 0x8d12A197cB00D4747a1fe03395095ce2A5CC6819)").Default(defaultContractAddress).StringVar(&contractAddress) + kingpin.Flag("token", "The token's contract address (required)").Required().StringVar(&tokenAddress) + kingpin.Flag("keystore-file", "The owner's Keystore file location (required)").Required().ExistingFileVar(&keyStorePath) + kingpin.Flag("passphrase", "The Keystore file's passphrase (required)").Required().StringVar(&passphrase) + kingpin.Flag("skip-withdraw", "Don't withdraw remaining tokens (optional, default: false)").BoolVar(&skipWithdraw) + kingpin.Flag("gas-price", "The gas price in wei (optional, default: 1000000000, i.e. 1 Gwei)").Default(defaultGasPrice).Int64Var(&gasPrice) + kingpin.Flag("gas-limit", "The gas limit; default: 100000").Default(defaultGasLimit).Int64Var(&gasLimit) } func main() { + // Parse command line flags. kingpin.Version(version) kingpin.Parse() + // Create an Ethereum client connecting to the provided RPC endpoint. c, err := rpc.DialHTTP(endpoint) if err != nil { log.Fatalf("Failed to connect to the Ethereum network: %v", err) } conn := ethclient.NewClient(c) + // Create an instance of the EtherDelta contract at the specific address. etherdelta, err := contract.NewEtherDelta(common.HexToAddress(contractAddress), conn) if err != nil { log.Fatalf("Failed to instantiate EtherDelta contract instance: %v", err) } + // Open the provided Keystore file. + keyStoreFile, err := os.OpenFile(keyStorePath, os.O_RDONLY, 0400) + if err != nil { + log.Fatalf("Failed to open Keystore file: %v", err) + } + defer keyStoreFile.Close() + + // Read the Keystore file's JSON content. + keyStoreJSON, err := ioutil.ReadAll(keyStoreFile) + if err != nil { + log.Fatalf("Failed to parse Keystore file: %v", err) + } + + // Parse and decrypt the content given a passphrase. + ownerKey, err := keystore.DecryptKey(keyStoreJSON, passphrase) + if err != nil { + log.Fatalf("Failed to decrypt Keystore file: %v", err) + } + + // Get the owner's as well as the token's addresses. + owner := ownerKey.Address token := common.HexToAddress(tokenAddress) - owner := common.HexToAddress(ownerAddress) + // Retrieve the owner's deposited balance of the token. balance, err := etherdelta.BalanceOf(nil, token, owner) if err != nil { log.Fatalf("Failed to retrieve balance: %v", err) } - fmt.Println("Token balance:", balance) + fmt.Println("Deposited tokens:", balance) - if balance.Cmp(common.Big0) == 0 { + // If withdrawal is disabled or there's nothing to withdraw, end the program. + if skipWithdraw || balance.Cmp(common.Big0) == 0 { os.Exit(0) } + // Otherwise withdraw the deposited tokens. fmt.Println("Withdrawing tokens:", balance) - privateKey, err := crypto.ToECDSA(common.Hex2Bytes(ownerPrivateKey)) - if err != nil { - log.Fatalf("Failed to read private key: %v", err) - } - - opts := bind.NewKeyedTransactor(privateKey) + // The transaction needs additional metadata, e.g. the private key for + // authorization as well as gas price and limit. + opts := bind.NewKeyedTransactor(ownerKey.PrivateKey) opts.GasLimit = big.NewInt(gasLimit) - opts.GasPrice = big.NewInt(gasPrice * 1000000000) + opts.GasPrice = big.NewInt(gasPrice) + // Execute the WithdrawToken function of the EtherDelta contract given the + // required arguments: the token address and amount. Also provide the metadata + // created above in order to successfully create and sign the transaction. tx, err := etherdelta.WithdrawToken(opts, token, balance) if err != nil { log.Fatalf("Failed to withdraw tokens: %v", err) } + // Print the hash of the submitted transaction for tracking. fmt.Println("Transaction hash:", tx.Hash().String()) }