Skip to content

Commit e838ba0

Browse files
authored
Merge pull request #1 from IgweDaniel/feat/import-wallet
feat: add wallet import via private key for eth and btc
2 parents b72f52a + df9d0bb commit e838ba0

6 files changed

Lines changed: 346 additions & 0 deletions

File tree

backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ func backend() *pluginBackend {
3838
},
3939
Paths: framework.PathAppend(
4040
walletsPaths(&b),
41+
importPaths(&b),
4142
pathSign(&b),
4243
),
4344
Secrets: []*framework.Secret{},

internal/adapters/adapters.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ var ErrInvalidPayload = fmt.Errorf("invalid payload format")
88

99
type BlockchainAdapter interface {
1010
DeriveWallet() (*Wallet, error)
11+
ImportWallet(privateKey string) (*Wallet, error)
1112
CreateSignedTransaction(wallet *Wallet, payload string) (string, error)
1213
}

internal/adapters/btc_adapter.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,27 @@ func NewBtcAdapter(net *chaincfg.Params) *btcAdapter {
3939
return &btcAdapter{net: net}
4040
}
4141

42+
func (a *btcAdapter) ImportWallet(privateKeyWIF string) (*Wallet, error) {
43+
wif, err := btcutil.DecodeWIF(privateKeyWIF)
44+
if err != nil {
45+
return nil, fmt.Errorf("invalid WIF private key: %w", err)
46+
}
47+
48+
if !wif.IsForNet(a.net) {
49+
return nil, fmt.Errorf("private key is not for %s network", a.net.Name)
50+
}
51+
52+
addr, err := getPubKey(wif, a.net)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to derive address from private key: %w", err)
55+
}
56+
57+
return &Wallet{
58+
PrivateKey: wif.String(),
59+
PublicKey: addr.EncodeAddress(),
60+
}, nil
61+
}
62+
4263
func (a *btcAdapter) DeriveWallet() (*Wallet, error) {
4364
privateKey, err := btcec.NewPrivateKey()
4465
if err != nil {

internal/adapters/eth_adapter.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,27 @@ func NewEthAdapter() *ethereumAdapter {
3131
return &ethereumAdapter{}
3232
}
3333

34+
func (a *ethereumAdapter) ImportWallet(privateKeyHex string) (*Wallet, error) {
35+
if len(privateKeyHex) >= 2 && privateKeyHex[:2] == "0x" {
36+
privateKeyHex = privateKeyHex[2:]
37+
}
38+
39+
privateKey, err := crypto.HexToECDSA(privateKeyHex)
40+
if err != nil {
41+
return nil, fmt.Errorf("invalid private key: %w", err)
42+
}
43+
44+
publicKeyECDSA, ok := privateKey.Public().(*ecdsa.PublicKey)
45+
if !ok {
46+
return nil, fmt.Errorf("failed to derive public key from private key")
47+
}
48+
49+
return &Wallet{
50+
PrivateKey: privateKeyHex,
51+
PublicKey: crypto.PubkeyToAddress(*publicKeyECDSA).Hex(),
52+
}, nil
53+
}
54+
3455
func (a *ethereumAdapter) DeriveWallet() (*Wallet, error) {
3556
privateKey, err := crypto.GenerateKey()
3657
if err != nil {

path_import.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package vaultpoly
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
8+
"github.com/hashicorp/vault/sdk/framework"
9+
"github.com/hashicorp/vault/sdk/logical"
10+
"github.com/igwedaniel/vaultpoly/internal/adapters"
11+
)
12+
13+
func importPaths(b *pluginBackend) []*framework.Path {
14+
return []*framework.Path{
15+
{
16+
Pattern: "wallets/" + framework.GenericNameRegex("blockchainType") + "/import",
17+
HelpSynopsis: "Import an existing wallet by providing a private key.",
18+
HelpDescription: `
19+
20+
POST - import a wallet by providing its private key.
21+
For Ethereum: provide the hex-encoded private key (with or without 0x prefix).
22+
For Bitcoin: provide the WIF-encoded private key.
23+
24+
`,
25+
Fields: map[string]*framework.FieldSchema{
26+
"blockchainType": {
27+
Type: framework.TypeString,
28+
Default: "eth",
29+
Description: "The blockchain type for the wallet. Currently supported: 'eth', 'btc', 'tbtc'.",
30+
AllowedValues: adapters.AllowedBlockchains(),
31+
},
32+
"private_key": {
33+
Type: framework.TypeString,
34+
Required: true,
35+
Description: "The private key to import. Hex-encoded for Ethereum, WIF-encoded for Bitcoin.",
36+
},
37+
},
38+
39+
Callbacks: map[logical.Operation]framework.OperationFunc{
40+
logical.UpdateOperation: b.pathWalletImport,
41+
},
42+
},
43+
}
44+
}
45+
46+
func (b *pluginBackend) pathWalletImport(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) {
47+
blockchainType := adapters.BlockchainType(d.Get("blockchainType").(string))
48+
if !blockchainType.IsValid() {
49+
return nil, fmt.Errorf("invalid blockchain type: %s", blockchainType)
50+
}
51+
52+
privateKey := d.Get("private_key").(string)
53+
if privateKey == "" {
54+
return nil, logical.CodedError(http.StatusBadRequest, "private_key is required")
55+
}
56+
57+
adapter, err := adapters.GetAdapter(blockchainType)
58+
if err != nil {
59+
return nil, err
60+
}
61+
62+
wallet, err := adapter.ImportWallet(privateKey)
63+
if err != nil {
64+
b.Logger().Error("Failed to import wallet", "error", err)
65+
return nil, logical.CodedError(http.StatusBadRequest, fmt.Sprintf("failed to import wallet: %s", err))
66+
}
67+
68+
walletPath := fmt.Sprintf("wallets/%s/%s", blockchainType, wallet.PublicKey)
69+
70+
existing, err := req.Storage.Get(ctx, walletPath)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to check for existing wallet: %w", err)
73+
}
74+
if existing != nil {
75+
return nil, logical.CodedError(http.StatusConflict, fmt.Sprintf("wallet already exists: %s", wallet.PublicKey))
76+
}
77+
78+
entry, err := logical.StorageEntryJSON(walletPath, wallet)
79+
if err != nil {
80+
b.Logger().Error("Failed to create storage entry for imported wallet", "error", err)
81+
return nil, fmt.Errorf("failed to create storage entry for wallet: %w", err)
82+
}
83+
84+
err = req.Storage.Put(ctx, entry)
85+
if err != nil {
86+
b.Logger().Error("Failed to save the imported wallet to storage", "error", err)
87+
return nil, err
88+
}
89+
90+
return &logical.Response{
91+
Data: map[string]interface{}{
92+
"address": wallet.PublicKey,
93+
},
94+
}, nil
95+
}

path_import_test.go

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package vaultpoly
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/hex"
7+
"encoding/json"
8+
"math/big"
9+
"strings"
10+
"testing"
11+
12+
btcec "github.com/btcsuite/btcd/btcec/v2"
13+
"github.com/btcsuite/btcd/btcec/v2/ecdsa"
14+
"github.com/btcsuite/btcd/btcutil"
15+
"github.com/btcsuite/btcd/chaincfg"
16+
"github.com/btcsuite/btcd/txscript"
17+
"github.com/btcsuite/btcd/wire"
18+
"github.com/ethereum/go-ethereum/core/types"
19+
"github.com/ethereum/go-ethereum/rlp"
20+
"github.com/hashicorp/vault/sdk/logical"
21+
"github.com/igwedaniel/vaultpoly/internal/adapters"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
func TestImportAndSign(t *testing.T) {
26+
b, s := getTestBackend(t)
27+
28+
t.Run("Import and sign ETH", func(t *testing.T) {
29+
testImportAndSignETH(t, b, s)
30+
})
31+
32+
t.Run("Import and sign BTC", func(t *testing.T) {
33+
testImportAndSignBTC(t, b, s)
34+
})
35+
}
36+
37+
func testImportAndSignETH(t *testing.T, b *pluginBackend, s logical.Storage) {
38+
importResp, err := testWalletImport(t, b, s, adapters.BlockchainETH.String(),
39+
"aa18efe8e8b1da4488e9f1350ad1f25ad387229177907ddb5c57e9cc22a74592")
40+
require.NoError(t, err)
41+
require.NotNil(t, importResp)
42+
require.Nil(t, importResp.Error())
43+
44+
address := importResp.Data["address"].(string)
45+
require.NotEmpty(t, address)
46+
47+
payload := adapters.EthPayload{
48+
ChainID: 97,
49+
To: "0x337610d27c682E347C9cD60BD4b3b107C9d34dDd",
50+
Value: 0,
51+
Data: "0xa9059cbb000000000000000000000000253f9dd15f4bd360595b0e83d51ef31d8e71d31b0000000000000000000000000000000000000000000000000de0b6b3a7640000",
52+
Nonce: 0,
53+
GasLimit: 60000,
54+
GasPrice: 1000000000,
55+
}
56+
jsonB, _ := json.Marshal(payload)
57+
58+
signResp, err := testWalletSign(t, b, s, adapters.BlockchainETH.String(), address, map[string]interface{}{
59+
"payload": string(jsonB),
60+
})
61+
require.NoError(t, err)
62+
require.NotNil(t, signResp)
63+
require.Nil(t, signResp.Error())
64+
65+
signature := signResp.Data["signature"].(string)
66+
require.NotEmpty(t, signature)
67+
68+
txBytes, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
69+
require.NoError(t, err, "Failed to decode transaction hex")
70+
71+
var tx types.Transaction
72+
err = rlp.DecodeBytes(txBytes, &tx)
73+
require.NoError(t, err, "Failed to decode RLP transaction")
74+
75+
require.NotNil(t, tx.To(), "Transaction should have a recipient")
76+
require.Equal(t, uint64(payload.GasLimit), tx.Gas())
77+
require.Equal(t, uint64(payload.Nonce), tx.Nonce())
78+
require.Equal(t, strings.TrimPrefix(payload.Data, "0x"), hex.EncodeToString(tx.Data()))
79+
require.Equal(t, strings.ToLower(payload.To), strings.ToLower(tx.To().Hex()))
80+
81+
expectedValue := big.NewInt(int64(payload.Value))
82+
require.Equal(t, 0, expectedValue.Cmp(tx.Value()))
83+
84+
expectedGasPrice := big.NewInt(int64(payload.GasPrice))
85+
require.Equal(t, 0, expectedGasPrice.Cmp(tx.GasPrice()))
86+
87+
chainID := big.NewInt(int64(payload.ChainID))
88+
signer := types.NewEIP155Signer(chainID)
89+
recoveredAddress, err := types.Sender(signer, &tx)
90+
require.NoError(t, err, "Failed to recover signer address")
91+
require.Equal(t, strings.ToLower(address), strings.ToLower(recoveredAddress.Hex()),
92+
"Recovered signer address doesn't match imported wallet address")
93+
}
94+
95+
func testImportAndSignBTC(t *testing.T, b *pluginBackend, s logical.Storage) {
96+
pk, err := btcec.NewPrivateKey()
97+
require.NoError(t, err)
98+
wif, err := btcutil.NewWIF(pk, &chaincfg.TestNet4Params, true)
99+
require.NoError(t, err)
100+
101+
importResp, err := testWalletImport(t, b, s, adapters.BlockchainBTCTestnet.String(), wif.String())
102+
require.NoError(t, err)
103+
require.NotNil(t, importResp)
104+
require.Nil(t, importResp.Error())
105+
106+
address := importResp.Data["address"].(string)
107+
require.NotEmpty(t, address)
108+
109+
addr, err := btcutil.DecodeAddress(address, &chaincfg.TestNet4Params)
110+
require.NoError(t, err)
111+
script, err := txscript.PayToAddrScript(addr)
112+
require.NoError(t, err)
113+
114+
addressPubScriptKey := hex.EncodeToString(script)
115+
utxos := []adapters.UTXO{
116+
{
117+
Txid: "9404a6b8f40b9fd4b868b0305a16eddfd1bcd8477c2f71bbc1588ba8884208c3",
118+
Vout: 1,
119+
Value: 500000,
120+
ScriptPubKey: addressPubScriptKey,
121+
ScriptPubKeyType: "v0_p2wpkh",
122+
},
123+
}
124+
125+
amount := int64(200000)
126+
recipient := "tb1qpn5dddjnc2qwurpsm449l6uvggnjxwsetrnksx"
127+
payload := testBtcPayload(amount, recipient, utxos)
128+
129+
signResp, err := testWalletSign(t, b, s, adapters.BlockchainBTCTestnet.String(), address, map[string]interface{}{
130+
"payload": payload,
131+
})
132+
require.NoError(t, err)
133+
require.NotNil(t, signResp)
134+
require.Nil(t, signResp.Error())
135+
136+
signature := signResp.Data["signature"].(string)
137+
require.NotEmpty(t, signature)
138+
139+
txBytes, err := hex.DecodeString(signature)
140+
require.NoError(t, err)
141+
142+
var tx wire.MsgTx
143+
err = tx.Deserialize(bytes.NewReader(txBytes))
144+
require.NoError(t, err, "Transaction should deserialize")
145+
require.Equal(t, 2, len(tx.TxOut), "Expected 2 outputs (recipient + change)")
146+
147+
recipientAddr, err := btcutil.DecodeAddress(recipient, &chaincfg.TestNet4Params)
148+
require.NoError(t, err)
149+
recipientScript, err := txscript.PayToAddrScript(recipientAddr)
150+
require.NoError(t, err)
151+
require.True(t, bytes.Equal(tx.TxOut[0].PkScript, recipientScript), "Recipient script mismatch")
152+
require.Equal(t, amount, tx.TxOut[0].Value, "Recipient amount mismatch")
153+
154+
utxo := utxos[0]
155+
prevScript, err := hex.DecodeString(utxo.ScriptPubKey)
156+
require.NoError(t, err)
157+
158+
txIn := tx.TxIn[0]
159+
require.NotEmpty(t, txIn.Witness, "P2WPKH transaction should have witness data")
160+
require.Equal(t, 2, len(txIn.Witness), "P2WPKH witness should have 2 elements (signature + pubkey)")
161+
162+
sigBytes := txIn.Witness[0]
163+
pubKeyBytes := txIn.Witness[1]
164+
165+
sigBytesNoHashType := sigBytes[:len(sigBytes)-1]
166+
parsedSig, err := ecdsa.ParseDERSignature(sigBytesNoHashType)
167+
require.NoError(t, err, "Failed to parse signature")
168+
169+
pubKey, err := btcec.ParsePubKey(pubKeyBytes)
170+
require.NoError(t, err, "Failed to parse public key")
171+
172+
hash := btcutil.Hash160(pubKey.SerializeCompressed())
173+
derivedAddr, err := btcutil.NewAddressWitnessPubKeyHash(hash, &chaincfg.TestNet4Params)
174+
require.NoError(t, err)
175+
require.Equal(t, address, derivedAddr.EncodeAddress(),
176+
"Signing public key doesn't match imported wallet address")
177+
178+
sigHashes := txscript.NewTxSigHashes(&tx, txscript.NewCannedPrevOutputFetcher(prevScript, utxo.Value))
179+
sigHash, err := txscript.CalcWitnessSigHash(prevScript, sigHashes, txscript.SigHashAll, &tx, 0, utxo.Value)
180+
require.NoError(t, err)
181+
require.True(t, parsedSig.Verify(sigHash, pubKey), "Signature verification failed")
182+
183+
prevOutputFetcher := txscript.NewCannedPrevOutputFetcher(prevScript, utxo.Value)
184+
engine, err := txscript.NewEngine(prevScript, &tx, 0, txscript.StandardVerifyFlags, nil, nil, utxo.Value, prevOutputFetcher)
185+
require.NoError(t, err)
186+
err = engine.Execute()
187+
require.NoError(t, err, "Script execution failed - transaction signature is invalid")
188+
189+
totalOutput := int64(0)
190+
for _, out := range tx.TxOut {
191+
totalOutput += out.Value
192+
}
193+
actualFee := utxo.Value - totalOutput
194+
require.True(t, actualFee > 0, "Transaction fee should be positive")
195+
}
196+
197+
func testWalletImport(t *testing.T, b *pluginBackend, s logical.Storage, blockchainType string, privateKey string) (*logical.Response, error) {
198+
t.Helper()
199+
return b.HandleRequest(context.Background(), &logical.Request{
200+
Operation: logical.UpdateOperation,
201+
Path: "wallets/" + blockchainType + "/import",
202+
Data: map[string]interface{}{
203+
"private_key": privateKey,
204+
},
205+
Storage: s,
206+
})
207+
}

0 commit comments

Comments
 (0)