diff --git a/cgo/transactions.go b/cgo/transactions.go index 76eb810..4aa8673 100644 --- a/cgo/transactions.go +++ b/cgo/transactions.go @@ -91,6 +91,22 @@ func sendRawTransaction(cName, cTxHex *C.char) *C.char { return successCResponse("%s", txHash) } +//export abandonTransaction +func abandonTransaction(cName, cTxID *C.char) *C.char { + w, exists := loadedWallet(cName) + if !exists { + return errCResponse("wallet with name %q does not exist", goString(cName)) + } + txHash, err := chainhash.NewHashFromStr(goString(cTxID)) + if err != nil { + return errCResponse("invalid tx hash: %v", err) + } + if err := w.AbandonTransaction(w.ctx, txHash); err != nil { + return errCResponse("unable to abandon transaction: %v", err) + } + return successCResponse("%s", txHash) +} + //export listUnspents func listUnspents(cName *C.char) *C.char { w, exists := loadedWallet(cName) diff --git a/dcr/dcr_test.go b/dcr/dcr_test.go index 79e5b91..811ca3d 100644 --- a/dcr/dcr_test.go +++ b/dcr/dcr_test.go @@ -6,6 +6,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/decred/dcrd/chaincfg/v3" "github.com/decred/dcrd/hdkeychain/v3" + "github.com/decred/dcrd/wire" ) func TestAddrFromExtendedKey(t *testing.T) { @@ -144,6 +145,70 @@ func TestDecodeTx(t *testing.T) { } } +func TestIsDust(t *testing.T) { + // Standard P2PKH script (25 bytes). Output serialize size is 36. + // Dust threshold = 10 * (36 + 165) * 3 = 6030 atoms. + p2pkhScript := []byte{ + 0x76, 0xa9, 0x14, // OP_DUP OP_HASH160 OP_DATA_20 + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, // 20-byte pubkey hash + 0x88, 0xac, // OP_EQUALVERIFY OP_CHECKSIG + } + opReturnScript := []byte{0x6a, 0x04, 0x01, 0x02, 0x03, 0x04} + + tests := []struct { + name string + value int64 + script []byte + want bool + }{{ + name: "zero value is dust", + value: 0, + script: p2pkhScript, + want: true, + }, { + name: "below threshold is dust", + value: 6029, + script: p2pkhScript, + want: true, + }, { + name: "at threshold is not dust", + value: 6030, + script: p2pkhScript, + want: false, + }, { + name: "above threshold is not dust", + value: 6031, + script: p2pkhScript, + want: false, + }, { + name: "OP_RETURN is always dust", + value: 100000, + script: opReturnScript, + want: true, + }} + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + txOut := &wire.TxOut{Value: test.value, PkScript: test.script} + if got := isDust(txOut); got != test.want { + t.Fatalf("isDust(%d) = %v, want %v", test.value, got, test.want) + } + }) + } +} + +func TestDustThreshold(t *testing.T) { + // For a P2PKH output (serialize size 36): + // threshold = 10 * (36 + 165) * 3 = 6030 + const p2pkhSerSize = 36 + got := dustThreshold(p2pkhSerSize) + if got != 6030 { + t.Fatalf("dustThreshold(%d) = %d, want 6030", p2pkhSerSize, got) + } +} + func TestDecryptSeed(t *testing.T) { metaData := new(walletData) w := &Wallet{metaData: metaData} diff --git a/dcr/transactions.go b/dcr/transactions.go index 7e0a0d0..2b62dfa 100644 --- a/dcr/transactions.go +++ b/dcr/transactions.go @@ -31,8 +31,30 @@ const ( // sstxCommitmentString is the string to insert when a verbose // transaction output's pkscript type is a ticket commitment. sstxCommitmentString = "sstxcommitment" + // defaultRelayFeePerByte is the default relay fee in atoms per byte. + defaultRelayFeePerByte = defaultRelayFeePerKb / 1000 ) +// isDust returns whether the given output value is considered dust at the +// default relay fee rate. See dcrd/internal/mempool/policy.go for the dust +// policy documentation. An output is dust when: +// +// value / (3 * (outputSize + 165)) < relayFeePerByte +func isDust(txOut *wire.TxOut) bool { + if txscript.IsUnspendable(txOut.Value, txOut.PkScript) { + return true + } + totalSize := uint64(txOut.SerializeSize()) + 165 + return uint64(txOut.Value)/(3*totalSize) < defaultRelayFeePerByte +} + +// dustThreshold returns the minimum non-dust value in atoms for the given +// output size. +func dustThreshold(txOutSize int) int64 { + totalSize := uint64(txOutSize) + 165 + return int64(defaultRelayFeePerByte * totalSize * 3) +} + // newTxOut returns a new transaction output with the given parameters. func newTxOut(amount int64, pkScriptVer uint16, pkScript []byte) *wire.TxOut { return &wire.TxOut{ @@ -217,6 +239,12 @@ func (w *Wallet) CreateTransaction(ctx context.Context, outputs []*Output, } payScriptVer, payScript := addr.PaymentScript() txOut := newTxOut(int64(out.Amount), payScriptVer, payScript) + if !sendAll && isDust(txOut) { + minVal := dustThreshold(txOut.SerializeSize()) + return nil, nil, 0, fmt.Errorf("output %d is dust: payment of %d atoms "+ + "is below the dust threshold of %d atoms for address %s", + i, out.Amount, minVal, out.Address) + } outs[i] = txOut } @@ -288,6 +316,13 @@ func (w *Wallet) SendRawTransaction(ctx context.Context, txHex string) (*chainha return w.mainWallet.PublishTransaction(ctx, msgTx, w.syncer) } +// AbandonTransaction removes an unmined transaction, identified by its hash, +// from the wallet. All transaction spend chains deriving from the transaction's +// outputs are also removed. +func (w *Wallet) AbandonTransaction(ctx context.Context, txHash *chainhash.Hash) error { + return w.mainWallet.AbandonTransaction(ctx, txHash) +} + // createVinList returns a slice of JSON objects for the inputs of the passed // transaction. func createVinList(mtx *wire.MsgTx, isTreasuryEnabled bool) []dcrdtypes.Vin {