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 RedistributeV2 #79

Merged
merged 1 commit into from
Aug 6, 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
123 changes: 111 additions & 12 deletions wallet/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -555,14 +555,11 @@ func (sw *SingleAddressWallet) UnconfirmedTransactions() (annotated []Event, err
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) {
func (sw *SingleAddressWallet) selectRedistributeUTXOs(bh uint64, outputs int, amount types.Currency) ([]types.SiacoinElement, int, error) {
// fetch outputs from the store
elements, err := sw.store.UnspentSiacoinElements()
if err != nil {
return nil, nil, err
return nil, 0, err
}

// fetch outputs currently in the pool
Expand All @@ -573,13 +570,6 @@ func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte type
}
}

// 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
utxos := make([]types.SiacoinElement, 0, len(elements))
Expand All @@ -598,6 +588,25 @@ func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte type
utxos = append(utxos, sce)
}
}
// desc sort
sort.Slice(utxos, func(i, j int) bool {
return utxos[i].SiacoinOutput.Value.Cmp(utxos[j].SiacoinOutput.Value) > 0
})
return utxos, outputs, 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) {
state := sw.cm.TipState()
sw.mu.Lock()
defer sw.mu.Unlock()

utxos, outputs, err := sw.selectRedistributeUTXOs(state.Index.Height, outputs, amount)
if err != nil {
return nil, nil, err
}

// return early if we don't have to defrag at all
if outputs <= 0 {
Expand Down Expand Up @@ -679,6 +688,96 @@ func (sw *SingleAddressWallet) Redistribute(outputs int, amount, feePerByte type
return
}

// RedistributeV2 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) RedistributeV2(outputs int, amount, feePerByte types.Currency) (txns []types.V2Transaction, toSign [][]int, err error) {
state := sw.cm.TipState()
sw.mu.Lock()
defer sw.mu.Unlock()

utxos, outputs, err := sw.selectRedistributeUTXOs(state.Index.Height, outputs, amount)
if err != nil {
return nil, nil, err
}

// 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 txnIdx, toSignTxn := range toSign {
for i := range toSignTxn {
delete(sw.locked, txns[txnIdx].SiacoinInputs[i].Parent.ID)
}
}
}
}()

// prepare defrag transactions
for outputs > 0 {
var txn types.V2Transaction
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.V2TransactionWeight(txn))
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
if !fee.IsZero() {
txn.MinerFee = 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
toSignTxn := make([]int, 0, len(inputs))
for _, sce := range inputs {
toSignTxn = append(toSignTxn, len(txn.SiacoinInputs))
txn.SiacoinInputs = append(txn.SiacoinInputs, types.V2SiacoinInput{
Parent: sce,
})
sw.locked[sce.ID] = time.Now().Add(sw.cfg.ReservationDuration)
}
txns = append(txns, txn)
toSign = append(toSign, toSignTxn)
}
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.
Expand Down
95 changes: 95 additions & 0 deletions wallet/wallet_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,101 @@ func TestWalletRedistribute(t *testing.T) {
}
}

func TestWalletRedistributeV2(t *testing.T) {
// create wallet store
pk := types.GeneratePrivateKey()
ws := testutil.NewEphemeralWalletStore(pk)

// create chain store
network, genesis := testutil.Network()
network.HardforkV2.AllowHeight = 1 // allow V2 transactions from the start
cs, tipState, err := chain.NewDBStore(chain.NewMemDB(), network, genesis)
if err != nil {
t.Fatal(err)
}

// create chain manager and subscribe the wallet
cm := chain.NewManager(cs, tipState)
// create wallet
l := zaptest.NewLogger(t)
w, err := wallet.NewSingleAddressWallet(pk, cm, ws, wallet.WithLogger(l.Named("wallet")))
if err != nil {
t.Fatal(err)
}
defer w.Close()

// fund the wallet
mineAndSync(t, cm, ws, w, w.Address(), 1)
mineAndSync(t, cm, ws, w, types.VoidAddress, cm.TipState().MaturityHeight()-1)

redistribute := func(amount types.Currency, n int) error {
txns, toSign, err := w.RedistributeV2(n, amount, types.ZeroCurrency)
if err != nil {
return fmt.Errorf("redistribute failed: %w", err)
} else if len(txns) == 0 {
return nil
}

for i := 0; i < len(txns); i++ {
w.SignV2Inputs(cm.TipState(), &txns[i], toSign[i])
}
if _, err := cm.AddV2PoolTransactions(cm.Tip(), txns); err != nil {
return fmt.Errorf("failed to add transactions to pool: %w", err)
}
mineAndSync(t, cm, ws, w, types.VoidAddress, 1)
return nil
}

assertOutputs := func(amount types.Currency, n int) error {
utxos, err := w.SpendableOutputs()
if err != nil {
return fmt.Errorf("failed to get unspent outputs: %w", err)
}
var count int
for _, utxo := range utxos {
if utxo.SiacoinOutput.Value.Equals(amount) {
count++
}
}
if count != n {
return fmt.Errorf("expected %v outputs of %v, got %v", n, amount, count)
}
return nil
}

// assert we have one output
assertOutputs(tipState.BlockReward(), 1)

// redistribute the wallet into 4 outputs of 75KS
amount := types.Siacoins(75e3)
if err := redistribute(amount, 4); err != nil {
t.Fatal(err)
}
assertOutputs(amount, 4)

// redistribute the wallet into 4 outputs of 50KS
amount = types.Siacoins(50e3)
if err := redistribute(amount, 4); err != nil {
t.Fatal(err)
}
assertOutputs(amount, 4)

// redistribute the wallet into 3 outputs of 101KS - expect ErrNotEnoughFunds
if err := redistribute(types.Siacoins(101e3), 3); !errors.Is(err, wallet.ErrNotEnoughFunds) {
t.Fatalf("expected ErrNotEnoughFunds, got %v", err)
}

// redistribute the wallet into 3 outputs of 50KS - assert this is a no-op
txns, toSign, err := w.RedistributeV2(3, amount, types.ZeroCurrency)
if err != nil {
t.Fatal(err)
} else if len(txns) != 0 {
t.Fatalf("expected no transactions, got %v", len(txns))
} else if len(toSign) != 0 {
t.Fatalf("expected no ids, got %v", len(toSign))
}
}

func TestReorg(t *testing.T) {
// create wallet store
pk := types.GeneratePrivateKey()
Expand Down
Loading