Skip to content

Commit

Permalink
Automatic miner fee (#2)
Browse files Browse the repository at this point in the history
* automatic miner fee, no tests

* fee package

* call api

* update example

* add blackchain test

* return go.mod
  • Loading branch information
glossd authored Dec 3, 2024
1 parent 589c6e7 commit 39ffe85
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 39 deletions.
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,11 @@ import (
func main() {
net := netchain.TestNet
rawTx, err := txutil.Create(txutil.CreateParams{
PrivateKey: "your-key", // e.g. 932u6Q4xEC9UYRb3rS2BWrSpSPEt5KaU8NNP7EWy7zSkWmfBiGe
PrivateKey: "your-wallet-private-key", // e.g. 932u6Q4xEC9UYRb3rS2BWrSpSPEt5KaU8NNP7EWy7zSkWmfBiGe
Destination: "address", // e.g. n4kkk9H2jGj7t8LA4vxK4DHM7Lq95VaEXC
Amount: 500000, // satoshi to send
Net: net,
MinerFee: 5000,
})
if err != nil {
panic(err)
Expand All @@ -59,8 +60,21 @@ func main() {
*all the code can be found in the 'examples' folder.*

**For testing purposes** I used `netchain.TestNet`.
If you want to send real bitcoins to blockchain you need to specify BTC_API_KEY env var for blockcypher or you could pass your own txutil.CreateParams.Fetch function to txutil.Create,
refer to [examples/create-real-transaction](https://github.com/glossd/btc/blob/master/examples/create-real-transaction/main.go)

### Real bitcoin transaction
Refer to [examples/create-real-transaction](https://github.com/glossd/btc/blob/master/examples/create-real-transaction/main.go)
I use it to transfer real bitcoins. Here's my usual configuration.
```go
txutil.CreateParams{
PrivateKey: "your-wallet-private-key", // e.g. 932u6Q4xEC9UYRb3rS2BWrSpSPEt5KaU8NNP7EWy7zSkWmfBiGe
Destination: "address", // e.g. n4kkk9H2jGj7t8LA4vxK4DHM7Lq95VaEXC
SendAll: true,
Net: netchain.MainNet,
AutoMinerFee: true, // automatically calculates MinerFee
}
```
I rely on Blockcypher API to receive up-to-date information on the blockchain. You need to specify your own token with BTC_API_KEY env var.
Or you could pass your own txutil.CreateParams.Fetch function to txutil.Create.

---
### More options
Expand Down
7 changes: 5 additions & 2 deletions addressinfo/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import "github.com/glossd/btc/netchain"

type Address struct {
Balance int64
UTXOs []UTXO
UTXOs []UTXO
}

type UTXO struct {
Expand All @@ -14,4 +14,7 @@ type UTXO struct {
TxOutIdx int
}

type Fetch func(address string, net netchain.Net) (Address, error)
type Fetch func(address string, net netchain.Net) (Address, error)

// GetSatoshiPerByte returns minimum 'good-enough' satoshi per byte rate.
type GetSatoshiPerByte func(net netchain.Net) (int, error)
36 changes: 32 additions & 4 deletions addressinfo/blockchain.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ type blockchainResponse struct {
}

type blockchainUTXO struct {
TxID string `json:"tx_hash_big_endian"`
TxOutputN int `json:"tx_output_n"`
Script string `json:"script"`
Value int64 `json:"value"`
TxID string `json:"tx_hash_big_endian"`
TxOutputN int `json:"tx_output_n"`
Script string `json:"script"`
Value int64 `json:"value"`
}

func FetchFromBlockchain(address string, net netchain.Net) (Address, error) {
Expand Down Expand Up @@ -51,3 +51,31 @@ func FetchFromBlockchain(address string, net netchain.Net) (Address, error) {
}
return Address{UTXOs: utxos, Balance: balance}, nil
}

func GetSatoshiPerByteFromBlockchain(net netchain.Net) (int, error) {
if net != netchain.MainNet {
return 0, fmt.Errorf("only mainnet is supported for blockchain.info")
}
resp, err := http.Get(fmt.Sprintf("https://api.blockchain.info/mempool/fees"))
if err != nil {
return 0, err
}
defer resp.Body.Close()
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return 0, err
}
type response struct {
Priority int `json:"priority"`
}

var res response
err = json.Unmarshal(bodyBytes, &res)
if err != nil {
return 0, err
}
if res.Priority == 0 {
return 0, fmt.Errorf("priority field is 0")
}
return res.Priority, nil
}
8 changes: 8 additions & 0 deletions addressinfo/blockchain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,11 @@ func TestFetchFromBlockchain(t *testing.T) {
assert.Positive(t, len(got.UTXOs))
})
}

func TestGetSatoshiPerByteFromBlockchain(t *testing.T) {
spb, err := GetSatoshiPerByteFromBlockchain(netchain.MainNet)
if err != nil {
t.Fatal(err)
}
assert.Positive(t, spb)
}
13 changes: 8 additions & 5 deletions examples/create-real-transaction/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,18 @@ import (
func main() {
os.Setenv("BTC_API_KEY", "you_token") // set your Blockcypher Token e.g. 40f3102a0bbf409d1642a0d4ba31d3df
rawTx, err := txutil.Create(txutil.CreateParams{
PrivateKey: "your key", // e.g. 932u6Q4xEC9UYRb3rS2BWrSpSPEt5KaU8NNP7EWy7zSkWmfBiGe
Destination: "address", // e.g. n4kkk9H2jGj7t8LA4vxK4DHM7Lq95VaEXC
Amount: 150000, // satoshi to send
Net: netchain.MainNet,
MinerFee: 4000, // set good miner fee for the transaction to be picked up
PrivateKey: "your key", // e.g. 932u6Q4xEC9UYRb3rS2BWrSpSPEt5KaU8NNP7EWy7zSkWmfBiGe
Destination: "address", // e.g. n4kkk9H2jGj7t8LA4vxK4DHM7Lq95VaEXC
Amount: 150000, // satoshi to send
Net: netchain.MainNet,
AutoMinerFee: true,
})
if err != nil {
panic(err)
}

fmt.Println(rawTx) // copy the output and broadcast it anywhere e.g. https://www.blockchain.com/explorer/assets/btc/broadcast-transaction
// You can decode the transaction https://www.blockchain.com/explorer/assets/btc/decode-transaction
// and sum up the value fields of all the outs. Then take it from the wallet balance.
// MinerFee = BtcOnYourWaller - SumOfOuts.
}
50 changes: 42 additions & 8 deletions txutil/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import (
"strconv"
)

const defaultMinerFee = 5000
const DefaultMinerFee = 5000

const maxMinerFee = 50000

// https://support.blockchain.com/hc/en-us/articles/210354003-What-is-the-minimum-amount-I-can-send-
const minSatoshiToSend = 546

Expand All @@ -28,16 +31,20 @@ type CreateParams struct {
// Bitcoin address of the receiver. Amount or SendAll must be set. Will be omitted if Destinations are specified.
Destination string
// Parameter for Destination. Measured in satoshi. Will be omitted if SendAll is true.
Amount int64
Amount int64
Destinations []Destination
// If true, all satoshi will be sent. Only works if you specified only one destination.
SendAll bool
// In satoshi, defaults to defaultMinerFee.
// In satoshi, defaults to DefaultMinerFee. Will be omitted if AutoMinerFee is true.
MinerFee int64
// Automatically calculates MinerFee. Will call an API addressinfo.GetSatoshiPerByte.
AutoMinerFee bool
// defaults to netchain.MainNet.
Net netchain.Net
// defaults to addressinfo.FetchFromBlockcypher.
Fetch addressinfo.Fetch
// defaults to addressinfo.GetSatoshiPerByteFromBlockchain.
GetSatoshiPerByte addressinfo.GetSatoshiPerByte

pkInfos []privateKeyInfo
destInfos []destinationInfo
Expand Down Expand Up @@ -73,6 +80,30 @@ func Create(params CreateParams) (string, error) {
return "", err
}

txHex, err := buildTx(params, addrs)
if err != nil {
return "", err
}

if params.AutoMinerFee {
// calculating miner fee. In case %2==1, added +1
bytesNum := (len(txHex) + 1) / 2
satoshiPerByte, err := params.GetSatoshiPerByte(params.Net)
if err != nil {
return "", fmt.Errorf("couldn't fetch satoshiPerByte: %s", err)
}
params.MinerFee = int64(bytesNum * satoshiPerByte)
if params.MinerFee > maxMinerFee {
// preventing any possible losses
return "", fmt.Errorf("the maximum auto miner fee is reached, max=%d, got=%d", maxMinerFee, params.MinerFee)
}
return buildTx(params, addrs)
} else {
return txHex, nil
}
}

func buildTx(params CreateParams, addrs []address) (string, error) {
tx := wire.NewMsgTx(wire.TxVersion)

satoshiRemainder, err := addUTXOsToTxInputs(tx, addrs, params)
Expand All @@ -92,14 +123,17 @@ func Create(params CreateParams) (string, error) {

func checkCreateParams(p CreateParams) (CreateParams, error) {
if p.MinerFee == 0 {
p.MinerFee = defaultMinerFee
p.MinerFee = DefaultMinerFee
}
if p.Net == "" {
p.Net = netchain.MainNet
}
if p.Fetch == nil {
p.Fetch = addressinfo.FetchFromBlockcypher
}
if p.GetSatoshiPerByte == nil {
p.GetSatoshiPerByte = addressinfo.GetSatoshiPerByteFromBlockchain
}

if len(p.Destinations) == 0 {
if p.Destination == "" {
Expand Down Expand Up @@ -163,7 +197,7 @@ type address struct {
privateKey string
}

func getAddressesToWithdrawFrom(params CreateParams) ([]address, error){
func getAddressesToWithdrawFrom(params CreateParams) ([]address, error) {
var addrsToWithdrawFrom []address
var satoshiSum int64
for _, pkInfo := range params.pkInfos {
Expand All @@ -184,7 +218,7 @@ func getAddressesToWithdrawFrom(params CreateParams) ([]address, error){
}
}

func addUTXOsToTxInputs(tx *wire.MsgTx, addrs []address, params CreateParams,) (satoshiRemainder int64, err error) {
func addUTXOsToTxInputs(tx *wire.MsgTx, addrs []address, params CreateParams) (satoshiRemainder int64, err error) {
amountLeftToRedeem := params.fullCost()
for i, addr := range addrs {
isLastAddr := i == len(addrs)-1
Expand Down Expand Up @@ -318,12 +352,12 @@ func signTx(tx *wire.MsgTx, addresses []address) error {
if err != nil {
return fmt.Errorf("signing transaction failed, could compute hash utxo=%v", u)
}
utxosToSpendMap[h.String() + strconv.Itoa(u.TxOutIdx)] = utxoWithKey{UTXO: u, wif: wif}
utxosToSpendMap[h.String()+strconv.Itoa(u.TxOutIdx)] = utxoWithKey{UTXO: u, wif: wif}
}
}

for i, in := range tx.TxIn {
utxoOfIn := utxosToSpendMap[in.PreviousOutPoint.Hash.String() + strconv.Itoa(int(in.PreviousOutPoint.Index))]
utxoOfIn := utxosToSpendMap[in.PreviousOutPoint.Hash.String()+strconv.Itoa(int(in.PreviousOutPoint.Index))]
sourcePkString, err := hex.DecodeString(utxoOfIn.Pbscript)
if err != nil {
return err
Expand Down
52 changes: 35 additions & 17 deletions txutil/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ import (
const privateKey1 = "932u6Q4xEC9UYRb3rS2BWrSpSPEt5KaU8NNP7EWy7zSkWmfBiGe"
const privateKey2 = "cMvRbsVJKjRkZTV7tosWEYEu1x8tQcnLEbC64RiKwPeeEz29j8QZ"
const privateKey3 = "93UVjiGYyB6q16iMPuKjYePdLesaYvdMyP3EjE1PjZEqzd456h1"

// destination of each private key
const destination1 = "mgFv6afUVhrdd3D6mY2iyWzHVk5b64qTok"
const destination2 = "n4kkk9H2jGj7t8LA4vxK4DHM7Lq95VaEXC"
const destination3 = "mwRL1TpsRSFy5KXbxEd2KrHiD16VvbbAdj"


func TestCreate_SendAll(t *testing.T) {
t.Run("Through amount", func(t *testing.T) {
rawTx, err := Create(CreateParams{
PrivateKey: privateKey1,
Destination: destination2,
Amount: addressinfo.MockAddressBalance - defaultMinerFee,
Fetch: addressinfo.FetchMock,
Amount: addressinfo.MockAddressBalance - DefaultMinerFee,
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
})
assert.Nil(t, err)
Expand All @@ -34,11 +34,11 @@ func TestCreate_SendAll(t *testing.T) {
})
t.Run("SendAll flag true", func(t *testing.T) {
rawTx, err := Create(CreateParams{
PrivateKey: privateKey1,
PrivateKey: privateKey1,
Destination: destination2,
SendAll: true,
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
SendAll: true,
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
})
assert.Nil(t, err)

Expand All @@ -48,22 +48,43 @@ func TestCreate_SendAll(t *testing.T) {
})
}

func TestCreate_AutoMinerFee(t *testing.T) {
var amount int64 = 5e5
rawTx, err := Create(CreateParams{
PrivateKey: privateKey1,
Destination: destination2,
Amount: amount,
Fetch: addressinfo.FetchMock,
GetSatoshiPerByte: func(net netchain.Net) (int, error) {
return 10, nil
},
Net: netchain.TestNet,
AutoMinerFee: true,
})
assert.Nil(t, err)
tx := decodeTx(t, rawTx)
assert.EqualValues(t, 1, len(tx.TxIn))
assert.EqualValues(t, 2, len(tx.TxOut))
assert.EqualValues(t, tx.TxOut[0].Value, amount)
assert.EqualValues(t, tx.TxOut[1].Value, 497430) // this number shouldn't change over time, the bytes of transaction should stay the same
}

func TestCreate_MultiplePrivateKeys(t *testing.T) {
t.Run("SendAll", func(t *testing.T) {
rawTx, err := Create(CreateParams{
PrivateKeys: []string{privateKey1, privateKey2},
Destination: destination3,
SendAll: true,
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
SendAll: true,
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
})
assert.Nil(t, err)

tx := decodeTx(t, rawTx)
assert.GreaterOrEqual(t, len(tx.TxIn), 2)
})
t.Run("WithRemainder", func(t *testing.T) {
amount := addressinfo.MockAddressBalance*3/2 - defaultMinerFee
amount := addressinfo.MockAddressBalance*3/2 - DefaultMinerFee
rawTx, err := Create(CreateParams{
PrivateKeys: []string{privateKey1, privateKey2},
Destination: destination3,
Expand All @@ -82,13 +103,12 @@ func TestCreate_MultiplePrivateKeys(t *testing.T) {
})
}


func TestCreate_ToMultipleDestinations(t *testing.T) {
rawTx, err := Create(CreateParams{
PrivateKey: privateKey1,
PrivateKey: privateKey1,
Destinations: []Destination{{Address: destination2, Amount: 200000}, {Address: destination3, Amount: 300000}},
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
Fetch: addressinfo.FetchMock,
Net: netchain.TestNet,
})
assert.Nil(t, err)

Expand Down Expand Up @@ -152,5 +172,3 @@ func addressPkScript(t *testing.T, address string) []byte {
assert.Nil(t, err)
return script
}


0 comments on commit 39ffe85

Please sign in to comment.