diff --git a/Makefile b/Makefile index 9f5b98e..3b6e6d8 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ run: clean export OCEAN_STATS_INTERVAL=120; \ export OCEAN_ELECTRUM_URL=tcp://localhost:50001; \ export OCEAN_UTXO_EXPIRY_DURATION_IN_SECONDS=60; \ + export OCEAN_DB_TYPE=badger; \ go run ./cmd/oceand test: fmt diff --git a/cmd/oceand/main.go b/cmd/oceand/main.go index 4c5e292..d1dee91 100644 --- a/cmd/oceand/main.go +++ b/cmd/oceand/main.go @@ -49,6 +49,7 @@ var ( dbPort = config.GetInt(config.DbPortKey) dbName = config.GetString(config.DbNameKey) migrationSourceURL = config.GetString(config.DbMigrationPath) + dustAmount = uint64(config.GetInt(config.DustAmountKey)) ) func main() { @@ -87,6 +88,7 @@ func main() { RootPath: rootPath, Network: network, UtxoExpiryDuration: utxoExpiryDuration * time.Second, + DustAmount: dustAmount, RepoManagerType: dbType, BlockchainScannerType: bcScannerType, RepoManagerConfig: repoManagerConfig, diff --git a/go.mod b/go.mod index ceee2a0..b6741ff 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/stretchr/testify v1.8.1 github.com/timshannon/badgerhold/v4 v4.0.2 github.com/vulpemventures/go-bip39 v1.0.2 - github.com/vulpemventures/go-elements v0.4.5 + github.com/vulpemventures/go-elements v0.5.1 github.com/vulpemventures/neutrino-elements v0.1.3 golang.org/x/crypto v0.1.0 golang.org/x/net v0.7.0 @@ -83,7 +83,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/subosito/gotenv v1.3.0 // indirect github.com/vulpemventures/fastsha256 v0.0.0-20160815193821-637e65642941 // indirect - github.com/vulpemventures/go-secp256k1-zkp v1.1.5 // indirect + github.com/vulpemventures/go-secp256k1-zkp v1.1.6 // indirect go.opencensus.io v0.24.0 // indirect go.uber.org/atomic v1.10.0 // indirect golang.org/x/sys v0.5.0 // indirect diff --git a/go.sum b/go.sum index 47cae29..b3d6e08 100644 --- a/go.sum +++ b/go.sum @@ -1203,8 +1203,11 @@ github.com/vulpemventures/go-bip39 v1.0.2 h1:+BgKOVo04okKf1wA4Fhv8ccvql+qFyzVUdV github.com/vulpemventures/go-bip39 v1.0.2/go.mod h1:mjFmuv9D6BtANI6iscMmbHhmBOwjMCFfny3mxHnuDrk= github.com/vulpemventures/go-elements v0.4.5 h1:NlD68xcou9eYDIZiGTo7xpFuJV0IzcF4VQeix9cSdFU= github.com/vulpemventures/go-elements v0.4.5/go.mod h1:S7wy2QnrC2ElCZOscYMU2PYnnXRmuBQYfLMQGMVBqMg= +github.com/vulpemventures/go-elements v0.5.1 h1:F83n7dScOnAnpyH9VgWLdC68Qcva+2EWVzzNuW2UKak= +github.com/vulpemventures/go-elements v0.5.1/go.mod h1:aBGuWXHaiAIUIcwqCdtEh2iQ3kJjKwHU9ywvhlcRSeU= github.com/vulpemventures/go-secp256k1-zkp v1.1.5 h1:oG1kO8ibVQ1wOvYcnFyuI+2YqnEZluXdRwkOPJlHBQM= github.com/vulpemventures/go-secp256k1-zkp v1.1.5/go.mod h1:zo7CpgkuPgoe7fAV+inyxsI9IhGmcoFgyD8nqZaPSOM= +github.com/vulpemventures/go-secp256k1-zkp v1.1.6/go.mod h1:zo7CpgkuPgoe7fAV+inyxsI9IhGmcoFgyD8nqZaPSOM= github.com/vulpemventures/neutrino-elements v0.1.3 h1:rzg6G1oL2fWodOZ716SOs71JuqttPCjjmig7TrlHrxM= github.com/vulpemventures/neutrino-elements v0.1.3/go.mod h1:pSl1A515bPEw9PxnyVwxyTpXH5GZNMMBacOCs7Ioyds= github.com/willf/bitset v1.1.11-0.20200630133818-d5bec3311243/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= diff --git a/internal/app-config/config.go b/internal/app-config/config.go index d882bd6..b15c3d0 100644 --- a/internal/app-config/config.go +++ b/internal/app-config/config.go @@ -38,6 +38,7 @@ type AppConfig struct { RootPath string Network *network.Network UtxoExpiryDuration time.Duration + DustAmount uint64 RepoManagerType string BlockchainScannerType string @@ -59,6 +60,9 @@ func (c *AppConfig) Validate() error { if c.UtxoExpiryDuration == 0 { return fmt.Errorf("missing utxo expiry duration") } + if c.DustAmount == 0 { + return fmt.Errorf("missing dust amount threshold") + } if len(c.RepoManagerType) == 0 { return fmt.Errorf("missing repo manager type") } @@ -252,7 +256,7 @@ func (c *AppConfig) transactionService() *application.TransactionService { rm, _ := c.repoManager() bcs, _ := c.bcScanner() c.txSvc = application.NewTransactionService( - rm, bcs, c.Network, c.UtxoExpiryDuration, + rm, bcs, c.Network, c.UtxoExpiryDuration, c.DustAmount, ) return c.txSvc } diff --git a/internal/config/config.go b/internal/config/config.go index d014d36..bf1718a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -77,6 +77,8 @@ const ( DbNameKey = "DB_NAME" // DbMigrationPath is the path to migration files DbMigrationPath = "DB_MIGRATION_PATH" + // DustAmountKey is the key to customize the dust amount threshold + DustAmountKey = "DUST_AMOUNT" // DbLocation is the folder inside the datadir containing db files. DbLocation = "db" @@ -105,6 +107,7 @@ var ( defaultUtxoExpiryDuration = 360 // 6 minutes (3 blocks) defaultEsploraUrl = "https://blockstream.info/liquid/api" defaultElectrumUrl = "ssl://blockstream.info:995" + defaultDustAmount = uint64(450) supportedNetworks = map[string]*network.Network{ network.Liquid.Name: &network.Liquid, @@ -152,6 +155,7 @@ func init() { vip.SetDefault(DbNameKey, "oceand-db") vip.SetDefault(DbMigrationPath, "file://internal/infrastructure/storage/db/postgres/migration") vip.SetDefault(ElectrumUrlKey, defaultElectrumUrl) + vip.SetDefault(DustAmountKey, defaultDustAmount) if err := validate(); err != nil { log.Fatalf("invalid config: %s", err) diff --git a/internal/core/application/transaction_service.go b/internal/core/application/transaction_service.go index 18dc4d6..d4fca7c 100644 --- a/internal/core/application/transaction_service.go +++ b/internal/core/application/transaction_service.go @@ -54,20 +54,21 @@ type TransactionService struct { bcScanner ports.BlockchainScanner network *network.Network utxoExpiryDuration time.Duration + dustAmount uint64 log func(format string, a ...interface{}) } func NewTransactionService( repoManager ports.RepoManager, bcScanner ports.BlockchainScanner, - net *network.Network, utxoExpiryDuration time.Duration, + net *network.Network, utxoExpiryDuration time.Duration, dustAmount uint64, ) *TransactionService { logFn := func(format string, a ...interface{}) { format = fmt.Sprintf("transaction service: %s", format) log.Debugf(format, a...) } svc := &TransactionService{ - repoManager, bcScanner, net, utxoExpiryDuration, logFn, + repoManager, bcScanner, net, utxoExpiryDuration, dustAmount, logFn, } svc.registerHandlerForUtxoEvents() svc.registerHandlerForWalletEvents() @@ -328,6 +329,15 @@ func (ts *TransactionService) Transfer( ctx context.Context, accountName string, outputs Outputs, millisatsPerByte uint64, ) (string, error) { + // Ensure lbtc outs are not dust. + for _, out := range outputs { + if out.Asset == ts.network.AssetID { + if out.Amount < ts.dustAmount { + return "", fmt.Errorf("lbtc output amount must not be dust") + } + } + } + w, err := ts.getWallet(ctx) if err != nil { return "", err @@ -361,6 +371,7 @@ func (ts *TransactionService) Transfer( changeByAsset := make(map[string]uint64) selectedUtxos := make([]*domain.Utxo, 0) lbtc := ts.network.AssetID + dust := uint64(0) for targetAsset, targetAmount := range outputs.totalAmountByAsset() { utxos, change, err := DefaultCoinSelector.SelectUtxos(utxos, targetAmount, targetAsset) if err != nil { @@ -368,7 +379,12 @@ func (ts *TransactionService) Transfer( } selectedUtxos = append(selectedUtxos, utxos...) if change > 0 { - changeByAsset[targetAsset] = change + // If the lbtc change is dust, it is added as fee amount. + if targetAsset == lbtc && change < ts.dustAmount { + dust = change + } else { + changeByAsset[targetAsset] = change + } } } @@ -422,117 +438,134 @@ func (ts *TransactionService) Transfer( feeAmount := wallet.EstimateFees( inputs, append(outs, changeOutputs...), millisatsPerByte, ) - - // If feeAmount is lower than the lbtc change, it's enough to deduct it - // from the change amount. - if feeAmount < changeByAsset[lbtc] { - for i, out := range changeOutputs { - if out.Asset == lbtc { - changeOutputs[i].Amount -= feeAmount - break - } - } - } - // If feeAmount is exactly the lbtc change, it's enough to remove the - // change output. - if feeAmount == changeByAsset[lbtc] { - var outIndex int - for i, out := range changeOutputs { - if out.Asset == lbtc { - outIndex = i - break - } - } - changeOutputs = append( - changeOutputs[:outIndex], changeOutputs[outIndex+1:]..., - ) - } - // If feeAmount is greater than the lbtc change, another coin-selection round - // is required only in case the user is not trasferring the whole balance. - // In that case the fee amount is deducted from the lbtc output with biggest. - if feeAmount > changeByAsset[lbtc] { - if changeByAsset[lbtc] == 0 { - outIndex := 0 - for i, out := range outputs { + if dust >= feeAmount { + // If the dust amount covers the fee amount we are done as the dust + // just pays for the tx fees. + feeAmount = dust + } else { + // If lbtc change covers the fee amount, we subtract the latter from the + // former. The remaining lbtc change can be become fees if it end up being + // dust. + if feeAmount <= changeByAsset[lbtc] { + var outIndex int + for i, out := range changeOutputs { if out.Asset == lbtc { - if out.Amount > outputs[outIndex].Amount { - outIndex = i - } + outIndex = i + changeOutputs[i].Amount -= feeAmount + break } } - outs[outIndex] = wallet.Output{ - Asset: outs[outIndex].Asset, - Amount: outs[outIndex].Amount - feeAmount, - Script: outs[outIndex].Script, - BlindingKey: outs[outIndex].BlindingKey, - BlinderIndex: outs[outIndex].BlinderIndex, - } - } else { - targetAsset := lbtc - targetAmount := wallet.DummyFeeAmount - if feeAmount > targetAmount { - targetAmount = roundUpAmount(feeAmount) - } - - // Coin-selection must be done over remaining utxos. - remainingUtxos := getRemainingUtxos(utxos, selectedUtxos) - selectedUtxos, change, err := DefaultCoinSelector.SelectUtxos( - remainingUtxos, targetAmount, targetAsset, - ) - if err != nil { - return "", err + changeAmount := changeOutputs[outIndex].Amount + if changeAmount < ts.dustAmount { + changeOutputs = append( + changeOutputs[:outIndex], changeOutputs[outIndex+1:]..., + ) + feeAmount += changeAmount } + } - for _, u := range selectedUtxos { - input := wallet.Input{ - TxID: u.TxID, - TxIndex: u.VOut, - Value: u.Value, - Asset: u.Asset, - Script: u.Script, - ValueBlinder: u.ValueBlinder, - AssetBlinder: u.AssetBlinder, - ValueCommitment: u.ValueCommitment, - AssetCommitment: u.AssetCommitment, - Nonce: u.Nonce, + // If feeAmount is greater than the lbtc change, another coin-selection + // round is required, but only in case the user is not trasferring the + // whole balance. + if feeAmount > changeByAsset[lbtc] { + if changeByAsset[lbtc] == 0 { + // If there's no dust it means no change was actually produced during + // the first coin selection. In this case, the fee amount is subtracted + // from the output of the same asset with the biggest amount. + if dust == 0 { + outIndex := 0 + outAmount := uint64(0) + for i, out := range outputs { + if out.Asset == lbtc { + if out.Amount >= outAmount { + outIndex = i + outAmount = out.Amount + } + } + } + if outs[outIndex].Amount-feeAmount >= ts.dustAmount { + outs[outIndex] = wallet.Output{ + Asset: outs[outIndex].Asset, + Amount: outs[outIndex].Amount - feeAmount, + Script: outs[outIndex].Script, + BlindingKey: outs[outIndex].BlindingKey, + BlinderIndex: outs[outIndex].BlinderIndex, + } + } else { + // Otherwise the target output becomes fees. + feeAmount = outs[outIndex].Amount + outs = append( + outs[:outIndex], outs[outIndex+1:]..., + ) + } + } + } else { + targetAsset := lbtc + targetAmount := changeByAsset[lbtc] - feeAmount + + // Coin-selection must be done over remaining utxos. + remainingUtxos := getRemainingUtxos(utxos, selectedUtxos) + selectedUtxos, _, err := DefaultCoinSelector.SelectUtxos( + remainingUtxos, targetAmount, targetAsset, + ) + if err != nil { + return "", err } - inputs = append(inputs, input) - inputsByIndex[uint32(len(inputs))] = input - } - if change > 0 { - // For the eventual change amount, it might be necessary to add a lbtc - // change output to the list if it's still not in the list. - if _, ok := changeByAsset[targetAsset]; !ok { - addrInfo, err := walletRepo.DeriveNextInternalAddressesForAccount( - ctx, account.Namespace, 1, - ) - if err != nil { - return "", err + for _, u := range selectedUtxos { + input := wallet.Input{ + TxID: u.TxID, + TxIndex: u.VOut, + Value: u.Value, + Asset: u.Asset, + Script: u.Script, + ValueBlinder: u.ValueBlinder, + AssetBlinder: u.AssetBlinder, + ValueCommitment: u.ValueCommitment, + AssetCommitment: u.AssetCommitment, + Nonce: u.Nonce, } - script, _ := hex.DecodeString(addrInfo[0].Script) - changeOutputs = append(changeOutputs, wallet.Output{ - Amount: change, - Asset: targetAsset, - Script: script, - BlindingKey: addrInfo[0].BlindingKey, - }) + inputs = append(inputs, input) + inputsByIndex[uint32(len(inputs))] = input } // Now that we have all inputs and outputs, estimate the real fee amount. - feeAmount = wallet.EstimateFees( + feeAmount := wallet.EstimateFees( inputs, append(outs, changeOutputs...), millisatsPerByte, ) - // Update the change amount by adding the delta - // delta = targetAmount - feeAmount. + outAmount := uint64(0) + for _, out := range outs { + if out.Asset == lbtc { + outAmount += out.Amount + } + } + inAmount := uint64(0) + for _, in := range inputs { + if in.Asset == lbtc { + inAmount += in.Value + } + } + + // The change is calculated as: + // total in amount - (total out amount + fee amount) + changeAmount := inAmount - outAmount - feeAmount + changeIndex := 0 for i, out := range changeOutputs { - if out.Asset == targetAsset { - // This way the delta is subtracted in case it's negative. - changeOutputs[i].Amount = uint64(int(out.Amount) + int(targetAmount) - int(feeAmount)) + if out.Asset == lbtc { + changeIndex = i break } } + + // The change output is updated and eventually removed if became dust, + changeOutputs[changeIndex].Amount = changeAmount + if changeAmount < ts.dustAmount { + feeAmount += changeAmount + changeOutputs = append( + changeOutputs[:changeIndex], changeOutputs[changeIndex+1:]..., + ) + } } } } diff --git a/internal/core/application/transaction_service_test.go b/internal/core/application/transaction_service_test.go index bc37c13..3034cc1 100644 --- a/internal/core/application/transaction_service_test.go +++ b/internal/core/application/transaction_service_test.go @@ -28,6 +28,7 @@ var ( }, } utxoExpiryDuration = 2 * time.Minute + dustAmount = uint64(450) ) func TestTransactionService(t *testing.T) { @@ -46,7 +47,7 @@ func testExternalTransaction(t *testing.T) { require.NotNil(t, repoManager) svc := application.NewTransactionService( - repoManager, mockedBcScanner, regtest, utxoExpiryDuration, + repoManager, mockedBcScanner, regtest, utxoExpiryDuration, dustAmount, ) selectedUtxos, change, expirationDate, err := svc.SelectUtxos( @@ -127,7 +128,7 @@ func testInternalTransaction(t *testing.T) { require.NotNil(t, repoManager) svc := application.NewTransactionService( - repoManager, mockedBcScanner, regtest, utxoExpiryDuration, + repoManager, mockedBcScanner, regtest, utxoExpiryDuration, dustAmount, ) txid, err := svc.Transfer(ctx, accountName, outputs, 0) diff --git a/pkg/wallet/single-sig/sign.go b/pkg/wallet/single-sig/sign.go index 16e3ba2..61972af 100644 --- a/pkg/wallet/single-sig/sign.go +++ b/pkg/wallet/single-sig/sign.go @@ -135,7 +135,11 @@ func (w *Wallet) SignPset(args SignPsetArgs) (string, error) { ptx, _ := psetv2.NewPsetFromBase64(args.PsetBase64) for i, in := range ptx.Inputs { - path, ok := args.DerivationPathMap[hex.EncodeToString(in.GetUtxo().Script)] + prevout := in.GetUtxo() + if prevout == nil { + continue + } + path, ok := args.DerivationPathMap[hex.EncodeToString(prevout.Script)] if ok { err := w.signInput(ptx, i, path, args.sighashType()) if err != nil {