Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add SpendableOutputs, Redistribute and ReleaseInputs to SingleAddressWallet #11

Merged
merged 2 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 199 additions & 19 deletions wallet/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ const (
TxnSourceFoundationPayout TransactionSource = "foundation"
)

const (
// bytesPerInput is the encoded size of a SiacoinInput and corresponding
// TransactionSignature, assuming standard UnlockConditions.
bytesPerInput = 241
peterjan marked this conversation as resolved.
Show resolved Hide resolved

// redistributeBatchSize is the number of outputs to redistribute per txn to
// avoid creating a txn that is too large.
redistributeBatchSize = 10
)

var (
// ErrNotEnoughFunds is returned when there are not enough unspent outputs
// to fund a transaction.
Expand Down Expand Up @@ -193,21 +203,54 @@ func (sw *SingleAddressWallet) TransactionCount() (uint64, error) {
return sw.store.TransactionCount()
}

// SpendableOutputs returns a list of spendable siacoin outputs, a spendable
// output is an unspent output that's not locked, not currently in the
// transaction pool and that has matured.
func (sw *SingleAddressWallet) SpendableOutputs() ([]types.SiacoinElement, error) {
// fetch outputs from the store
utxos, err := sw.store.UnspentSiacoinElements()
if err != nil {
return nil, err
}

// fetch outputs currently in the pool
inPool := make(map[types.Hash256]bool)
for _, txn := range sw.cm.PoolTransactions() {
for _, sci := range txn.SiacoinInputs {
inPool[types.Hash256(sci.ParentID)] = true
}
}

// grab current height
state := sw.cm.TipState()
bh := state.Index.Height

sw.mu.Lock()
defer sw.mu.Unlock()

// filter outputs that are either locked, in the pool or have not yet matured
unspent := utxos[:0]
for _, sce := range utxos {
if time.Now().Before(sw.locked[sce.ID]) || inPool[sce.ID] || bh < sce.MaturityHeight {
continue
}
unspent = append(unspent, sce)
}
return unspent, nil
}

// FundTransaction adds siacoin inputs worth at least amount to the provided
// transaction. If necessary, a change output will also be added. The inputs
// will not be available to future calls to FundTransaction unless ReleaseInputs
// is called.
func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount types.Currency, useUnconfirmed bool) ([]types.Hash256, func(), error) {
func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount types.Currency, useUnconfirmed bool) ([]types.Hash256, error) {
if amount.IsZero() {
return nil, func() {}, nil
return nil, nil
}

sw.mu.Lock()
defer sw.mu.Unlock()

utxos, err := sw.store.UnspentSiacoinElements()
if err != nil {
return nil, nil, err
return nil, err
}

tpoolSpent := make(map[types.Hash256]bool)
Expand All @@ -227,6 +270,9 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty
}
}

sw.mu.Lock()
defer sw.mu.Unlock()

// remove locked and spent outputs
filtered := utxos[:0]
for _, sce := range utxos {
Expand Down Expand Up @@ -281,10 +327,10 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty

if inputSum.Cmp(amount) < 0 {
// still not enough funds
return nil, nil, ErrNotEnoughFunds
return nil, ErrNotEnoughFunds
}
} else if inputSum.Cmp(amount) < 0 {
return nil, nil, ErrNotEnoughFunds
return nil, ErrNotEnoughFunds
}

// check if remaining utxos should be defragged
Expand Down Expand Up @@ -325,14 +371,7 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty
sw.locked[sce.ID] = time.Now().Add(sw.cfg.ReservationDuration)
}

release := func() {
sw.mu.Lock()
defer sw.mu.Unlock()
for _, id := range toSign {
delete(sw.locked, id)
}
}
return toSign, release, nil
return toSign, nil
}

// SignTransaction adds a signature to each of the specified inputs.
Expand Down Expand Up @@ -364,9 +403,6 @@ func (sw *SingleAddressWallet) Tip() (types.ChainIndex, error) {
// UnconfirmedTransactions returns all unconfirmed transactions relevant to the
// wallet.
func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Transaction, error) {
sw.mu.Lock()
defer sw.mu.Unlock()

confirmed, err := sw.store.UnspentSiacoinElements()
if err != nil {
return nil, fmt.Errorf("failed to get unspent outputs: %w", err)
Expand Down Expand Up @@ -410,6 +446,142 @@ func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Transaction, error)
return annotated, nil
}

// Redistribute returns a transaction that redistributes money in the wallet by
// selecting a minimal set of inputs to cover the creation of the requested
// outputs. It also returns a list of output IDs that need to be signed.
func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte types.Currency) (txns []types.Transaction, toSign []types.Hash256, err error) {
// fetch outputs from the store
utxos, err := sw.store.UnspentSiacoinElements()
if err != nil {
return nil, nil, err
}

// fetch outputs currently in the pool
inPool := make(map[types.Hash256]bool)
for _, txn := range sw.cm.PoolTransactions() {
for _, sci := range txn.SiacoinInputs {
inPool[types.Hash256(sci.ParentID)] = true
}
}

// grab current height
state := sw.cm.TipState()
bh := state.Index.Height

sw.mu.Lock()
defer sw.mu.Unlock()

// adjust the number of desired outputs for any output we encounter that is
// unused, matured and has the same value
usable := utxos[:0]
for _, sce := range utxos {
inUse := time.Now().After(sw.locked[sce.ID]) || inPool[sce.ID]
matured := bh >= sce.MaturityHeight
sameValue := sce.SiacoinOutput.Value.Equals(amount)

// adjust number of desired outputs
if !inUse && matured && sameValue {
outputs--
}

// collect usable outputs for defragging
if !inUse && matured && !sameValue {
usable = append(usable, sce)
}
}
utxos = usable

// return early if we don't have to defrag at all
if outputs <= 0 {
return nil, nil, nil
}

// in case of an error we need to free all inputs
defer func() {
if err != nil {
for _, id := range toSign {
delete(sw.locked, id)
}
}
}()

// desc sort
sort.Slice(utxos, func(i, j int) bool {
return utxos[i].SiacoinOutput.Value.Cmp(utxos[j].SiacoinOutput.Value) > 0
})

// prepare defrag transactions
for outputs > 0 {
var txn types.Transaction
for i := 0; i < outputs && i < redistributeBatchSize; i++ {
txn.SiacoinOutputs = append(txn.SiacoinOutputs, types.SiacoinOutput{
Value: amount,
Address: sw.addr,
})
}
outputs -= len(txn.SiacoinOutputs)

// estimate the fees
outputFees := feePerByte.Mul64(state.TransactionWeight(txn))
n8maninger marked this conversation as resolved.
Show resolved Hide resolved
feePerInput := feePerByte.Mul64(bytesPerInput)

// collect outputs that cover the total amount
var inputs []types.SiacoinElement
want := amount.Mul64(uint64(len(txn.SiacoinOutputs)))
for _, sce := range utxos {
inputs = append(inputs, sce)
fee := feePerInput.Mul64(uint64(len(inputs))).Add(outputFees)
if SumOutputs(inputs).Cmp(want.Add(fee)) > 0 {
break
}
}

// not enough outputs found
fee := feePerInput.Mul64(uint64(len(inputs))).Add(outputFees)
if sumOut := SumOutputs(inputs); sumOut.Cmp(want.Add(fee)) < 0 {
return nil, nil, fmt.Errorf("%w: inputs %v < needed %v + txnFee %v", ErrNotEnoughFunds, sumOut.String(), want.String(), fee.String())
}

// set the miner fee
txn.MinerFees = []types.Currency{fee}

// add the change output
change := SumOutputs(inputs).Sub(want.Add(fee))
if !change.IsZero() {
txn.SiacoinOutputs = append(txn.SiacoinOutputs, types.SiacoinOutput{
Value: change,
Address: sw.addr,
})
}

// add the inputs
for _, sce := range inputs {
txn.SiacoinInputs = append(txn.SiacoinInputs, types.SiacoinInput{
ParentID: types.SiacoinOutputID(sce.ID),
UnlockConditions: types.StandardUnlockConditions(sw.priv.PublicKey()),
})
toSign = append(toSign, sce.ID)
sw.locked[sce.ID] = time.Now().Add(sw.cfg.ReservationDuration)
}
txns = append(txns, txn)
}

return
}

// ReleaseInputs is a helper function that releases the inputs of txn for use in
// other transactions. It should only be called on transactions that are invalid
// or will never be broadcast.
func (sw *SingleAddressWallet) ReleaseInputs(txns ...types.Transaction) {
sw.mu.Lock()
defer sw.mu.Unlock()
for _, txn := range txns {
for _, in := range txn.SiacoinInputs {
delete(sw.locked, types.Hash256(in.ParentID))
}
}
}

// IsRelevantTransaction returns true if the v1 transaction is relevant to the
// address
func IsRelevantTransaction(txn types.Transaction, addr types.Address) bool {
Expand Down Expand Up @@ -439,6 +611,14 @@ func IsRelevantTransaction(txn types.Transaction, addr types.Address) bool {
return false
}

// SumOutputs returns the total value of the supplied outputs.
func SumOutputs(outputs []types.SiacoinElement) (sum types.Currency) {
for _, o := range outputs {
sum = sum.Add(o.SiacoinOutput.Value)
}
return
}

// NewSingleAddressWallet returns a new SingleAddressWallet using the provided private key and store.
func NewSingleAddressWallet(priv types.PrivateKey, cm ChainManager, store SingleAddressStore, opts ...Option) (*SingleAddressWallet, error) {
cfg := config{
Expand Down
14 changes: 5 additions & 9 deletions wallet/wallet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,10 @@ func TestWallet(t *testing.T) {
}

// fund and sign the transaction
toSign, release, err := w.FundTransaction(&txn, initialReward, false)
toSign, err := w.FundTransaction(&txn, initialReward, false)
if err != nil {
t.Fatal(err)
}
defer release()
w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true})

// check that wallet now has no spendable balance
Expand Down Expand Up @@ -211,11 +210,10 @@ func TestWallet(t *testing.T) {
{Address: types.VoidAddress, Value: sendAmount},
}

toSign, release, err := w.FundTransaction(&sent[i], sendAmount, false)
toSign, err := w.FundTransaction(&sent[i], sendAmount, false)
if err != nil {
t.Fatal(err)
}
defer release()
w.SignTransaction(&sent[i], toSign, types.CoveredFields{WholeTransaction: true})
}

Expand Down Expand Up @@ -339,11 +337,10 @@ func TestWalletUnconfirmed(t *testing.T) {
},
}

toSign, release, err := w.FundTransaction(&txn, initialReward, false)
toSign, err := w.FundTransaction(&txn, initialReward, false)
if err != nil {
t.Fatal(err)
}
defer release()
w.SignTransaction(&txn, toSign, types.CoveredFields{WholeTransaction: true})

// check that wallet now has no spendable balance
Expand Down Expand Up @@ -378,16 +375,15 @@ func TestWalletUnconfirmed(t *testing.T) {
}

// try to send a new transaction without using the unconfirmed output
_, _, err = w.FundTransaction(&txn2, initialReward.Div64(2), false)
_, err = w.FundTransaction(&txn2, initialReward.Div64(2), false)
if !errors.Is(err, wallet.ErrNotEnoughFunds) {
t.Fatalf("expected funding error with no usable utxos, got %v", err)
}

toSign, release, err = w.FundTransaction(&txn2, initialReward.Div64(2), true)
toSign, err = w.FundTransaction(&txn2, initialReward.Div64(2), true)
if err != nil {
t.Fatal(err)
}
defer release()
w.SignTransaction(&txn2, toSign, types.CoveredFields{WholeTransaction: true})

// broadcast the transaction
Expand Down
Loading