diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dc28a2..2d53830 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,21 +34,30 @@ jobs: fi go build -o $PECTRA_BINARY_NAME cmd/main.go - # Build signer + # Build private key signer SIGNER_BINARY_NAME="signer" if [ "${{ matrix.goos }}" = "windows" ]; then SIGNER_BINARY_NAME="${SIGNER_BINARY_NAME}.exe" fi go build -o $SIGNER_BINARY_NAME scripts/sign.go + # Build mnemonic signer + MNEMONIC_SIGNER_BINARY_NAME="sign-mnemonic" + if [ "${{ matrix.goos }}" = "windows" ]; then + MNEMONIC_SIGNER_BINARY_NAME="${MNEMONIC_SIGNER_BINARY_NAME}.exe" + fi + go build -o $MNEMONIC_SIGNER_BINARY_NAME scripts/sign_mnemonic.go + # Create dist directory and move binaries mkdir -p dist if [ "${{ matrix.goos }}" = "windows" ]; then mv $PECTRA_BINARY_NAME "dist/pectra-cli-${{ matrix.goos }}-${{ matrix.goarch }}.exe" mv $SIGNER_BINARY_NAME "dist/signer-${{ matrix.goos }}-${{ matrix.goarch }}.exe" + mv $MNEMONIC_SIGNER_BINARY_NAME "dist/sign-mnemonic-${{ matrix.goos }}-${{ matrix.goarch }}.exe" else mv $PECTRA_BINARY_NAME "dist/pectra-cli-${{ matrix.goos }}-${{ matrix.goarch }}" mv $SIGNER_BINARY_NAME "dist/signer-${{ matrix.goos }}-${{ matrix.goarch }}" + mv $MNEMONIC_SIGNER_BINARY_NAME "dist/sign-mnemonic-${{ matrix.goos }}-${{ matrix.goarch }}" fi - name: Create Release @@ -58,4 +67,4 @@ jobs: files: dist/* generate_release_notes: true env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/README.md b/README.md index 18869d7..5acdb7a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A powerful airgapped CLI tool for executing Ethereum validator operations including consolidation, switching, and both partial and full withdrawals with seamless batching enabled by EIP-7702. -# Background +## Background The Pectra upgrade (Prague + Electra) introduces key validator enhancements: consolidation allows merging multiple validators into one to simplify management and reduce overhead; switch enables validators to update their BLS keys, supporting strategies like autocompounding without needing to exit and re-enter; and execution layer exits allow validators to exit directly via the execution layer, streamlining the exit process and enabling better integration with smart contracts and tooling. @@ -28,15 +28,19 @@ Moreover, the CLI is designed to be airgapped, ensuring security by allowing use ## Installation -1. Ensure you have Go (version 1.21+ recommended, as per `.github/workflows/release.yml` line 18) installed on your system. -2. Clone the repository (if you haven't already). -3. Build the CLI tool: +1. Ensure you have Go (version 1.21+ recommended, as per .github/workflows/release.yml line 18) installed on your system. +2. Clone the repository (if you haven't already). +3. Build the CLI tool and mnemonic signer: ```bash + # Build main CLI go build -o pectra-cli ./cmd/main.go + + # Build mnemonic signer + go build -o sign-mnemonic scripts/sign_mnemonic.go ``` - - This will create an executable file named `pectra-cli` in the current directory. + + This will create two executables: pectra-cli and sign-mnemonic in the current directory. ## Deployed Contracts - Mainnet - [0x17c11FDdADac2b341F2455aFe988fec4c3ba26e3](https://etherscan.io/address/0x17c11FDdADac2b341F2455aFe988fec4c3ba26e3) @@ -44,9 +48,9 @@ Moreover, the CLI is designed to be airgapped, ensuring security by allowing use ## Configuration -Create a JSON configuration file named `config.json` in the same directory as the `pectra-cli` executable. You can use `sample_config.json` as a template. +Create a JSON configuration file named config.json in the same directory as the pectra-cli executable. You can use sample_config.json as a template. -**Example `config.json`:** +**Example config.json:** ```json { @@ -98,17 +102,52 @@ Create a JSON configuration file named `config.json` in the same directory as th ## Private Key Handling -Use the `--airgapped` or `-a` to run the CLI in airgapped mode; alternatively, omit the flag to sign directly in the CLI by providing the private key. The CLI will securely prompt you to enter it at runtime when an operation is initiated. -(See `internal/config/config.go` lines 88-114) +The CLI supports multiple secure signing methods: + +### 1. Airgapped Mode (Recommended) +Use the `--airgapped` or `-a` flag to generate unsigned transactions that can be signed later: + +```bash +./pectra-cli switch -c config.json -a +``` + +This creates an `unsigned_txn.json` file that can be signed using either: + +#### A) Private Key Signing +```bash +go run scripts/sign.go unsigned_txn.json +``` + +#### B) Mnemonic Phrase Signing +```bash +./sign-mnemonic unsigned_txn.json +``` + +The mnemonic signer will prompt for: +- Your BIP-39 seed phrase (12, 15, 18, 21, or 24 words) +- Derivation path (default: m/44'/60'/0'/0/0) + +Supported derivation paths: +- Standard Ethereum: m/44'/60'/0'/0/0 (default) +- Ledger Live: m/44'/60'/0'/0/0 +- MEW/MyCrypto: m/44'/60'/0'/0 +- Custom accounts: m/44'/60'/0'/0/[index] + +### 2. Direct Signing +For convenience, you can sign directly by providing the private key when prompted (not recommended for production use): + +```bash +./pectra-cli switch -c config.json +``` -⚠️ Ensure that correct private keys are provided for the validators — otherwise, transactions will succeed but no validator operation will occur, wasting gas.

+⚠ Ensure that correct private keys are provided for the validators — otherwise, transactions will succeed but no validator operation will occur, wasting gas.

## 📜 ABI Dependency -The CLI requires the Pectra batch contract's ABI. Place the `abi.json` file in the same directory as the `pectra-cli` executable. +The CLI requires the Pectra batch contract's ABI. Place the abi.json file in the same directory as the pectra-cli executable. (The ABI is loaded from `./abi.json` as seen in `cmd/main.go` line 49)

-## ⚠️ Unset Delegation +## ⚠ Unset Delegation > It is HIGHLY recommended to unset delegation after performing any operation. This helps with restoration of EOA functionality and prevents the address from being used as a smart contract. @@ -132,49 +171,55 @@ To get a gist of the CLI, run: ./pectra-cli --help ``` -Replace `` with one of the operations listed below and `config.json` with the path to your configuration file. +Replace with one of the operations listed below and config.json with the path to your configuration file. Add the `-a` or `--airgapped` flag to run the CLI in airgapped mode. ### Switch Validators -Updates deposit credentials for the validators specified in `config.json` under the `switch` section. You can switch up to 200 validators in a single batch. +Updates deposit credentials for the validators specified in config.json under the switch section. You can switch up to 200 validators in a single batch. ```bash ./pectra-cli switch -c config.json ``` -⚠️ Do not switch a validator that has already been switched — the transaction will succeed but the switch won't take effect, wasting gas.

+⚠ Do not switch a validator that has already been switched — the transaction will succeed but the switch won't take effect, wasting gas.

### Consolidate Validators -Consolidates funds from `sourceValidators` to `targetValidator` as specified in `config.json` under the `consolidate` section. You can consolidate from up to 63 source validators into one target validator. +Consolidates funds from sourceValidators to targetValidator as specified in config.json under the consolidate section. You can consolidate from up to 63 source validators into one target validator. ```bash ./pectra-cli consolidate -c config.json ``` -⚠️ Do not use exited validators as source or target — transactions will succeed but consolidation won't occur, wasting gas.

+⚠ Do not use exited validators as source or target — transactions will succeed but consolidation won't occur, wasting gas.

### Execution Layer (EL) Exit -Performs partial or full exits for validators specified in `config.json` under the `elExit` section. You can exit up to 200 validators in a single batch. +Performs partial or full exits for validators specified in config.json under the elExit section. You can exit up to 200 validators in a single batch. ```bash ./pectra-cli el-exit -c config.json ``` -⚠️ Do not attempt to exit a validator that has already exited — the transaction will succeed but no exit will occur, wasting gas.

+⚠ Do not attempt to exit a validator that has already exited — the transaction will succeed but no exit will occur, wasting gas.

### Signing and Broadcast for airgapped mode -To sign an unsigned transaction, use `scripts/sign.go` on the `unsigned_txn.json` file — this will generate a `signed_txn.json`. +After generating an unsigned transaction in airgapped mode, you have two signing options: +#### Option 1: Sign with Private Key ```bash go run scripts/sign.go unsigned_txn.json ``` -Once signed, use the CLI's broadcast command to submit the `signed_txn.json` to the network. +#### Option 2: Sign with Mnemonic Phrase +```bash +./sign-mnemonic unsigned_txn.json +``` + +Both methods will generate a signed_txn.json file. Broadcast it using: ```bash ./pectra-cli broadcast -c config.json -f signed_txn.json @@ -183,31 +228,36 @@ Once signed, use the CLI's broadcast command to submit the `signed_txn.json` to ## 📝 Important Notes - **Validator Public Keys**: All validator public keys in the `config.json` file must be in hexadecimal format, without the "0x" prefix. -- **Transaction Fees**: The fee required per validator for each operation (switch, consolidate, EL exit) is automatically fetched from the smart contract functions (`getConsolidationFee`, `getExitFee`). This fee is in Wei. The total transaction `value` sent will be `(number of validators) * (fee per validator)`. +- **Transaction Fees**: The fee required per validator for each operation (switch, consolidate, EL exit) is automatically fetched from the smart contract functions (`getConsolidationFee`, `getExitFee`). This fee is in Wei. The total transaction value sent will be `(number of validators) * (fee per validator)`. (Fee fetching logic: `cmd/main.go` lines 67-74, 78-79, 91-92, 105-106) and `internal/utils/utils.go` lines 98-125 - **Execution Layer (EL) Exits**: - - The `amount` specified in the `elExit.validators` section of `config.json` is in **Gwei** (1 ETH = 1,000,000,000 Gwei). + - The amount specified in the `elExit.validators` section of `config.json` is in **Gwei** (1 ETH = 1,000,000,000 Gwei). (See `internal/utils/utils.go` for usage notes, and `internal/operations/partialexit.go` lines 41-49 for handling) - - For a **full exit**, set `amount` to `0` (or `0.0`) and `confirmFullExit` to `true`. - - For a **partial exit**, specify the desired `amount` in Gwei (e.g., `10.0` for 10 Gwei) and ensure `confirmFullExit` is `false`. + - For a **full exit**, set amount to `0` (or `0.0`) and `confirmFullExit` to `true`. + - For a **partial exit**, specify the desired amount in Gwei (e.g., `10.0` for 10 Gwei) and ensure `confirmFullExit` is `false`. - **Transaction Authorization**: This tool utilizes EIP-7702 SetCode transaction authorization for its operations. (See `internal/transaction/transaction.go` lines 18-61) +- **Mnemonic Security**: When using the mnemonic signer: + - Never share your seed phrase + - Run the signer on an airgapped machine when possible + - Verify the derived address matches your expected wallet address + - Consider using a dedicated account for validator operations
+ ## Sample Output (The CLI provides informative output during its execution, including connection status, fees, transaction hashes, and success/failure messages.) ### Sample Output for airgapped mode -![Sample Airgapped Output](https://i.imgur.com/CfKpNsN.png) -
-![Sample Signing Output](https://i.imgur.com/M9GGQB0.png) -
+![Sample Airgapped Output](https://i.postimg.cc/nhVbfmWV/Screenshot-2025-07-12-205709.png) +![Sample Private key Signing Output](https://i.postimg.cc/yYcY80nF/Screenshot-2025-07-12-205643.png) +![Sample Mnemonic phrase Signing Output](https://i.postimg.cc/XqtBF3yX/Screenshot-2025-07-12-210006.png) ![Sample Broadcast Output](https://i.imgur.com/nIEZDl8.png)
### Sample Output for non-airgapped mode ![Sample Non Airgapped Output](https://i.imgur.com/L2wpleY.png) -_(Note: The sample output images might show older field names or values; refer to the current configuration guidelines.)_ +(Note: The sample output images might show older field names or values; refer to the current configuration guidelines.)
diff --git a/go.mod b/go.mod index e301901..740f35b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( github.com/ethereum/go-ethereum v1.15.11 github.com/fatih/color v1.18.0 github.com/holiman/uint256 v1.3.2 + github.com/tyler-smith/go-bip32 v1.0.0 + github.com/tyler-smith/go-bip39 v1.1.0 github.com/urfave/cli/v2 v2.27.6 golang.org/x/term v0.30.0 ) @@ -18,6 +20,8 @@ require ( ) require ( + github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e // indirect + github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect github.com/bits-and-blooms/bitset v1.20.0 // indirect diff --git a/go.sum b/go.sum index 6a16180..00d3c05 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e h1:ahyvB3q25YnZWly5Gq1ekg6jcmWaGj/vG/MhF4aisoc= +github.com/FactomProject/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:kGUqhHd//musdITWjFvNTHn90WG9bMLBEPQZ17Cmlpw= +github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec h1:1Qb69mGp/UtRPn422BH4/Y4Q3SLUrD9KHuDkm8iodFc= +github.com/FactomProject/btcutilecc v0.0.0-20130527213604-d3a63a5752ec/go.mod h1:CD8UlnlLDiqb36L110uqiP2iSflVjx9g/3U9hCI4q2U= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= @@ -14,6 +18,8 @@ github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e h1:0XBUw73chJ1VYSsfvcPvVT7auykAJce9FpRr10L6Qhw= +github.com/cmars/basen v0.0.0-20150613233007-fe3947df716e/go.mod h1:P13beTBKr5Q18lJe1rIoLUqjM+CB1zYrRg44ZqGuQSA= github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= @@ -38,6 +44,7 @@ github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOV github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4= github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= @@ -172,6 +179,7 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/stretchr/testify v1.1.5-0.20170601210322-f6abca593680/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo= @@ -182,20 +190,28 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= -github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/tyler-smith/go-bip32 v1.0.0 h1:sDR9juArbUgX+bO/iblgZnMPeWY1KZMUC2AFUJdv5KE= +github.com/tyler-smith/go-bip32 v1.0.0/go.mod h1:onot+eHknzV4BVPwrzqY5OoVpyCvnwD7lMawL5aQupE= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U= github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/crypto v0.0.0-20170613210332-850760c427c5/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -207,6 +223,7 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= @@ -219,5 +236,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54= +launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM= rsc.io/tmplfunc v0.0.3 h1:53XFQh69AfOa8Tw0Jm7t+GV7KZhOi6jzsCzTtKbMvzU= rsc.io/tmplfunc v0.0.3/go.mod h1:AG3sTPzElb1Io3Yg4voV9AGZJuleGAwaVRxL9M49PhA= diff --git a/scripts/sign_mnemonic.go b/scripts/sign_mnemonic.go new file mode 100644 index 0000000..7e62992 --- /dev/null +++ b/scripts/sign_mnemonic.go @@ -0,0 +1,299 @@ +package main + +import ( + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "os" + "strconv" + "strings" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rlp" + "github.com/fatih/color" + "github.com/tyler-smith/go-bip32" + "github.com/tyler-smith/go-bip39" + "golang.org/x/term" +) + +const ( + // Default derivation path for Ethereum accounts + defaultDerivationPath = "m/44'/60'/0'/0/0" +) + +func main() { + color.Green("Pectra CLI - Mnemonic-based Transaction Signer") + color.Cyan("This tool signs transactions using a mnemonic phrase (seed phrase)") + fmt.Println() + + // Get mnemonic from user + mnemonic, err := getMnemonic() + if err != nil { + log.Fatalf("Failed to get mnemonic: %v", err) + } + + // Get derivation path from user + derivationPath, err := getDerivationPath() + if err != nil { + log.Fatalf("Failed to get derivation path: %v", err) + } + + // Derive private key from mnemonic + privateKey, address, err := derivePrivateKeyFromMnemonic(mnemonic, derivationPath) + if err != nil { + log.Fatalf("Failed to derive private key from mnemonic: %v", err) + } + + color.Green("Derived address: %s", address) + fmt.Println() + + // Determine input filename + inputFile := "unsigned_txn.json" + if len(os.Args) > 1 { + inputFile = os.Args[1] + } + + color.Cyan("Reading unsigned transaction from: %s", inputFile) + + // Read the unsigned transaction from file + data, err := os.ReadFile(inputFile) + if err != nil { + log.Fatalf("Failed to read %s: %v", inputFile, err) + } + + var txData struct { + UnsignedTransaction string `json:"unsignedTransaction"` + ChainId string `json:"chainId"` + } + + if err := json.Unmarshal(data, &txData); err != nil { + log.Fatalf("Failed to parse JSON: %v", err) + } + + hexTx := txData.UnsignedTransaction + + // Remove 0x prefix if present + if len(hexTx) > 2 && hexTx[0:2] == "0x" { + hexTx = hexTx[2:] + } + + // Decode hex to bytes + txBytes, err := hex.DecodeString(hexTx) + if err != nil { + log.Fatalf("Failed to decode hex string: %v", err) + } + + // Decode transaction + tx := new(types.Transaction) + err = rlp.DecodeBytes(txBytes, tx) + if err != nil { + log.Fatalf("Failed to decode transaction: %v", err) + } + + color.Cyan("Transaction details:") + color.White(" Hash: %s", tx.Hash().Hex()) + color.White(" Chain ID: %s", tx.ChainId().String()) + color.White(" To: %s", tx.To().Hex()) + color.White(" Value: %s Wei", tx.Value().String()) + color.White(" Gas: %d", tx.Gas()) + fmt.Println() + + // Confirm signing + if !confirmSigning() { + color.Yellow("Transaction signing cancelled by user") + return + } + + // Sign the authorization + color.Cyan("Signing authorization...") + signedAuthorization, err := types.SignSetCode(privateKey, tx.SetCodeAuthorizations()[0]) + if err != nil { + log.Fatalf("Failed to sign the authorization: %v", err) + } + + // Update the transaction with signed authorization + tx.SetCodeAuthorizations()[0] = signedAuthorization + + // Sign the transaction + color.Cyan("Signing transaction...") + signedTx, err := types.SignTx(tx, types.LatestSignerForChainID(tx.ChainId()), privateKey) + if err != nil { + log.Fatalf("Failed to sign the transaction: %v", err) + } + + // Encode the signed transaction + signedTxBytes, err := rlp.EncodeToBytes(signedTx) + if err != nil { + log.Fatalf("Failed to encode signed transaction: %v", err) + } + + // Write the signed transaction to a file + signedData := map[string]string{ + "signedTransaction": hex.EncodeToString(signedTxBytes), + } + + jsonData, err := json.MarshalIndent(signedData, "", " ") + if err != nil { + log.Fatalf("Failed to marshal to JSON: %v", err) + } + + outputFile := "signed_txn.json" + if err := os.WriteFile(outputFile, jsonData, 0644); err != nil { + log.Fatalf("Failed to write to %s: %v", outputFile, err) + } + + color.Green("Transaction successfully signed!") + color.Green("Signed transaction written to: %s", outputFile) + color.Cyan("You can now broadcast this transaction using: ./pectra-cli broadcast -f %s -c config.json", outputFile) +} + +// getMnemonic securely prompts the user for their mnemonic phrase +func getMnemonic() (string, error) { + color.Cyan("Please enter your mnemonic phrase (12, 15, 18, 21, or 24 words):") + color.Yellow("Note: The mnemonic will not be displayed for security. Just type/paste and press Enter.") + fmt.Print("> ") + + // Read mnemonic without echoing to terminal + bytePassword, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", fmt.Errorf("failed to read mnemonic: %w", err) + } + fmt.Println() + + mnemonic := strings.TrimSpace(string(bytePassword)) + + // Basic validation + words := strings.Fields(mnemonic) + if len(words) < 12 || len(words) > 24 { + return "", fmt.Errorf("invalid mnemonic length: expected 12-24 words, got %d", len(words)) + } + + // Validate mnemonic using BIP-39 + if !bip39.IsMnemonicValid(mnemonic) { + return "", fmt.Errorf("invalid mnemonic phrase") + } + + return mnemonic, nil +} + +// getDerivationPath prompts the user for a derivation path or uses the default +func getDerivationPath() (string, error) { + color.Cyan("Enter derivation path (or press Enter for default: %s):", defaultDerivationPath) + color.Yellow("Common paths:") + color.Yellow(" Ethereum (default): m/44'/60'/0'/0/0") + color.Yellow(" Ledger Live: m/44'/60'/0'/0/0") + color.Yellow(" MEW/MyCrypto: m/44'/60'/0'/0") + color.Yellow(" Custom account index: m/44'/60'/0'/0/[index]") + fmt.Print("> ") + + var input string + _, err := fmt.Scanln(&input) + if err != nil && err.Error() != "unexpected newline" { + return "", fmt.Errorf("failed to read derivation path: %w", err) + } + + input = strings.TrimSpace(input) + if input == "" { + return defaultDerivationPath, nil + } + + // Basic validation for derivation path format + if !strings.HasPrefix(input, "m/") { + return "", fmt.Errorf("derivation path must start with 'm/'") + } + + return input, nil +} + +// derivePrivateKeyFromMnemonic derives a private key from a mnemonic using the specified derivation path +func derivePrivateKeyFromMnemonic(mnemonic, derivationPath string) (*ecdsa.PrivateKey, string, error) { + // Generate seed from mnemonic + seed := bip39.NewSeed(mnemonic, "") + + // Create master key + masterKey, err := bip32.NewMasterKey(seed) + if err != nil { + return nil, "", fmt.Errorf("failed to create master key: %w", err) + } + + // Parse and apply derivation path + path, err := parseDerivationPath(derivationPath) + if err != nil { + return nil, "", fmt.Errorf("failed to parse derivation path: %w", err) + } + + key := masterKey + for _, index := range path { + key, err = key.NewChildKey(index) + if err != nil { + return nil, "", fmt.Errorf("failed to derive child key: %w", err) + } + } + + // Convert to ECDSA private key + privateKey, err := crypto.ToECDSA(key.Key) + if err != nil { + return nil, "", fmt.Errorf("failed to convert to ECDSA private key: %w", err) + } + + // Get the Ethereum address + address := crypto.PubkeyToAddress(privateKey.PublicKey).Hex() + + return privateKey, address, nil +} + +// parseDerivationPath parses a derivation path string into uint32 indexes +func parseDerivationPath(path string) ([]uint32, error) { + if !strings.HasPrefix(path, "m/") { + return nil, fmt.Errorf("derivation path must start with 'm/'") + } + + path = path[2:] + if path == "" { + return []uint32{}, nil + } + + parts := strings.Split(path, "/") + indexes := make([]uint32, len(parts)) + + for i, part := range parts { + var index uint64 + var err error + + if strings.HasSuffix(part, "'") { + part = part[:len(part)-1] + index, err = strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid derivation path component: %s", part) + } + index += 0x80000000 + } else { + index, err = strconv.ParseUint(part, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid derivation path component: %s", part) + } + } + + indexes[i] = uint32(index) + } + + return indexes, nil +} + +// confirmSigning asks the user to confirm they want to sign the transaction +func confirmSigning() bool { + color.Yellow("Are you sure you want to sign this transaction? (y/N): ") + fmt.Print("> ") + + var response string + _, err := fmt.Scanln(&response) + if err != nil { + return false + } + + response = strings.ToLower(strings.TrimSpace(response)) + return response == "y" || response == "yes" +} \ No newline at end of file