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 wallet apply and revert logic to coreutils #22

Merged
merged 6 commits into from Feb 16, 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Expand Up @@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]
go-version: [ '1.20', '1.21' ]
go-version: [ '1.21', '1.22' ]
runs-on: ${{ matrix.os }}
timeout-minutes: 10
steps:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
@@ -1,6 +1,6 @@
module go.sia.tech/coreutils

go 1.21
go 1.21.6

require (
go.etcd.io/bbolt v1.3.8
Expand Down
294 changes: 113 additions & 181 deletions testutil/wallet.go
Expand Up @@ -2,6 +2,8 @@ package testutil

import (
"fmt"
"slices"
"sort"
"sync"

"go.sia.tech/core/types"
Expand All @@ -11,220 +13,116 @@ import (

// An EphemeralWalletStore is a Store that does not persist its state to disk. It is
// primarily useful for testing or as a reference implementation.
type EphemeralWalletStore struct {
privateKey types.PrivateKey
type (
EphemeralWalletStore struct {
privateKey types.PrivateKey

mu sync.Mutex
uncommitted []*chain.ApplyUpdate
tip types.ChainIndex
utxos map[types.Hash256]types.SiacoinElement
mu sync.Mutex
uncommitted []*chain.ApplyUpdate

immaturePayoutTransactions map[uint64][]wallet.Transaction // transactions are never removed to simplify reorg handling
transactions []wallet.Transaction
}

// ProcessChainRevertUpdate implements chain.Subscriber
func (es *EphemeralWalletStore) ProcessChainRevertUpdate(cru *chain.RevertUpdate) error {
es.mu.Lock()
defer es.mu.Unlock()

// if the update is uncommitted, remove it from the uncommitted list
if len(es.uncommitted) > 0 && es.uncommitted[len(es.uncommitted)-1].State.Index == cru.State.Index {
es.uncommitted = es.uncommitted[:len(es.uncommitted)-1]
return nil
tip types.ChainIndex
utxos map[types.SiacoinOutputID]wallet.SiacoinElement
events []wallet.Event
}

walletAddress := types.StandardUnlockHash(es.privateKey.PublicKey())

// revert any siacoin element changes
cru.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) {
if se.SiacoinOutput.Address != walletAddress {
return
}

if spent {
es.utxos[se.ID] = se
} else {
delete(es.utxos, se.ID)
}
})

// remove any transactions that were added in the reverted block
filtered := es.transactions[:0]
for _, txn := range es.transactions {
if txn.Index == cru.State.Index {
continue
}
filtered = append(filtered, txn)
ephemeralWalletUpdateTxn struct {
store *EphemeralWalletStore
}
)

// update element proofs
for id, se := range es.utxos {
cru.UpdateElementProof(&se.StateElement)
es.utxos[id] = se
func (et *ephemeralWalletUpdateTxn) WalletStateElements() (elements []types.StateElement, _ error) {
for _, se := range et.store.utxos {
elements = append(elements, se.StateElement)
}
return nil
return
}

// ProcessChainApplyUpdate implements chain.Subscriber
func (es *EphemeralWalletStore) ProcessChainApplyUpdate(cau *chain.ApplyUpdate, commit bool) error {
es.mu.Lock()
defer es.mu.Unlock()

es.uncommitted = append(es.uncommitted, cau)
if !commit {
return nil
func (et *ephemeralWalletUpdateTxn) UpdateStateElements(elements []types.StateElement) error {
for _, se := range elements {
utxo := et.store.utxos[types.SiacoinOutputID(se.ID)]
utxo.StateElement = se
et.store.utxos[types.SiacoinOutputID(se.ID)] = utxo
}
return nil
}

walletAddress := types.StandardUnlockHash(es.privateKey.PublicKey())
func (et *ephemeralWalletUpdateTxn) AddEvents(events []wallet.Event) error {
et.store.events = append(events, et.store.events...)
return nil
}

for _, update := range es.uncommitted {
// cache the source of new immature outputs to show payout transactions
siacoinOutputSources := map[types.SiacoinOutputID]wallet.TransactionSource{
update.Block.ID().FoundationOutputID(): wallet.TxnSourceFoundationPayout,
}
// add the miner payouts
for i := range update.Block.MinerPayouts {
siacoinOutputSources[update.Block.ID().MinerOutputID(i)] = wallet.TxnSourceMinerPayout
func (et *ephemeralWalletUpdateTxn) AddSiacoinElements(elements []wallet.SiacoinElement) error {
for _, se := range elements {
if _, ok := et.store.utxos[types.SiacoinOutputID(se.ID)]; ok {
return fmt.Errorf("siacoin element %q already exists", se.ID)
}
et.store.utxos[types.SiacoinOutputID(se.ID)] = se
}
return nil
}

// add the file contract outputs
update.ForEachFileContractElement(func(fce types.FileContractElement, rev *types.FileContractElement, resolved bool, valid bool) {
if !resolved {
return
}

if valid {
for i := range fce.FileContract.ValidProofOutputs {
siacoinOutputSources[types.FileContractID(fce.ID).ValidOutputID(i)] = wallet.TxnSourceContract
}
} else {
for i := range fce.FileContract.MissedProofOutputs {
siacoinOutputSources[types.FileContractID(fce.ID).MissedOutputID(i)] = wallet.TxnSourceContract
}
}
})

// add matured transactions first since
for _, txn := range es.immaturePayoutTransactions[update.State.Index.Height] {
txn.Index = update.State.Index
txn.Timestamp = update.Block.Timestamp
// prepend the transaction to the wallet
es.transactions = append([]wallet.Transaction{txn}, es.transactions...)
func (et *ephemeralWalletUpdateTxn) RemoveSiacoinElements(ids []types.SiacoinOutputID) error {
for _, id := range ids {
if _, ok := et.store.utxos[id]; !ok {
return fmt.Errorf("siacoin element %q does not exist", id)
}
delete(et.store.utxos, id)
}
return nil
}

// add the block transactions
for _, txn := range update.Block.Transactions {
if !wallet.IsRelevantTransaction(txn, walletAddress) {
continue
}

wt := wallet.Transaction{
ID: txn.ID(),
Index: update.State.Index,
Transaction: txn,
Timestamp: update.Block.Timestamp,
Source: wallet.TxnSourceTransaction,
}

for _, sci := range txn.SiacoinInputs {
if sci.UnlockConditions.UnlockHash() != walletAddress {
continue
}

sce, ok := es.utxos[types.Hash256(sci.ParentID)]
if !ok {
return fmt.Errorf("missing relevant siacoin element %q", sci.ParentID)
}
wt.Outflow = wt.Outflow.Add(sce.SiacoinOutput.Value)
}

for _, sco := range txn.SiacoinOutputs {
if sco.Address != walletAddress {
continue
}

wt.Inflow = wt.Inflow.Add(sco.Value)
}

// prepend the transaction to the wallet
es.transactions = append([]wallet.Transaction{wt}, es.transactions...)

// add the siafund claim output IDs
for i := range txn.SiafundInputs {
siacoinOutputSources[txn.SiafundInputs[i].ParentID.ClaimOutputID()] = wallet.TxnSourceSiafundClaim
}
func (et *ephemeralWalletUpdateTxn) RevertIndex(index types.ChainIndex) error {
// remove any events that were added in the reverted block
filtered := et.store.events[:0]
for i := range et.store.events {
if et.store.events[i].Index == index {
continue
}
filtered = append(filtered, et.store.events[i])
}
et.store.events = filtered

// update the utxo set
update.ForEachSiacoinElement(func(se types.SiacoinElement, spent bool) {
if se.SiacoinOutput.Address != walletAddress {
return
}

// update the utxo set
if spent {
delete(es.utxos, se.ID)
} else {
es.utxos[se.ID] = se
}

// create an immature payout transaction for any immature siacoin outputs
if se.MaturityHeight == 0 || spent {
return
}

source, ok := siacoinOutputSources[types.SiacoinOutputID(se.ID)]
if !ok {
panic("missing siacoin source")
}

es.immaturePayoutTransactions[se.MaturityHeight] = append(es.immaturePayoutTransactions[se.MaturityHeight], wallet.Transaction{
ID: types.TransactionID(se.ID),
Transaction: types.Transaction{SiacoinOutputs: []types.SiacoinOutput{se.SiacoinOutput}},
Inflow: se.SiacoinOutput.Value,
Source: source,
// Index and Timestamp will be filled in later
})
})

// update the element proofs
for id, se := range es.utxos {
cau.UpdateElementProof(&se.StateElement)
es.utxos[id] = se
// remove any siacoin elements that were added in the reverted block
for id, se := range et.store.utxos {
if se.Index == index {
delete(et.store.utxos, id)
}
}

es.uncommitted = es.uncommitted[:0]
es.tip = cau.State.Index
return nil
}

// Transactions returns the wallet's transactions.
func (es *EphemeralWalletStore) Transactions(limit, offset int) ([]wallet.Transaction, error) {
// WalletEvents returns the wallet's events.
func (es *EphemeralWalletStore) WalletEvents(offset, limit int) ([]wallet.Event, error) {
es.mu.Lock()
defer es.mu.Unlock()

if offset > len(es.transactions) {
n := len(es.events)
start, end := offset, offset+limit
if start > n {
return nil, nil
} else if end > n {
end = n
}

end := offset + limit
if end > len(es.transactions) {
end = len(es.transactions)
}
return es.transactions[offset:end], nil
// events are inserted in chronological order, reverse the slice to get the
// correct display order then sort by maturity height, so
// immature events are displayed first.
events := append([]wallet.Event(nil), es.events...)
slices.Reverse(events)
sort.SliceStable(events, func(i, j int) bool {
return events[i].MaturityHeight > events[j].MaturityHeight
})
return events[start:end], nil
}

// TransactionCount returns the number of transactions in the wallet.
func (es *EphemeralWalletStore) TransactionCount() (uint64, error) {
// WalletEventCount returns the number of events relevant to the wallet.
func (es *EphemeralWalletStore) WalletEventCount() (uint64, error) {
es.mu.Lock()
defer es.mu.Unlock()
return uint64(len(es.transactions)), nil
return uint64(len(es.events)), nil
}

// UnspentSiacoinElements returns the wallet's unspent siacoin outputs.
func (es *EphemeralWalletStore) UnspentSiacoinElements() (utxos []types.SiacoinElement, _ error) {
func (es *EphemeralWalletStore) UnspentSiacoinElements() (utxos []wallet.SiacoinElement, _ error) {
es.mu.Lock()
defer es.mu.Unlock()

Expand All @@ -241,12 +139,46 @@ func (es *EphemeralWalletStore) Tip() (types.ChainIndex, error) {
return es.tip, nil
}

// ProcessChainApplyUpdate implements chain.Subscriber.
func (es *EphemeralWalletStore) ProcessChainApplyUpdate(cau *chain.ApplyUpdate, mayCommit bool) error {
es.mu.Lock()
defer es.mu.Unlock()

es.uncommitted = append(es.uncommitted, cau)
if !mayCommit {
return nil
}

address := types.StandardUnlockHash(es.privateKey.PublicKey())
ephemeralWalletUpdateTxn := &ephemeralWalletUpdateTxn{store: es}

if err := wallet.ApplyChainUpdates(ephemeralWalletUpdateTxn, address, es.uncommitted); err != nil {
return err
}
es.tip = cau.State.Index
es.uncommitted = nil
return nil
}

// ProcessChainRevertUpdate implements chain.Subscriber.
func (es *EphemeralWalletStore) ProcessChainRevertUpdate(cru *chain.RevertUpdate) error {
es.mu.Lock()
defer es.mu.Unlock()

if len(es.uncommitted) > 0 && es.uncommitted[len(es.uncommitted)-1].State.Index == cru.State.Index {
es.uncommitted = es.uncommitted[:len(es.uncommitted)-1]
return nil
}

address := types.StandardUnlockHash(es.privateKey.PublicKey())
return wallet.RevertChainUpdate(&ephemeralWalletUpdateTxn{store: es}, address, cru)
}

// NewEphemeralWalletStore returns a new EphemeralWalletStore.
func NewEphemeralWalletStore(pk types.PrivateKey) *EphemeralWalletStore {
return &EphemeralWalletStore{
privateKey: pk,

utxos: make(map[types.Hash256]types.SiacoinElement),
immaturePayoutTransactions: make(map[uint64][]wallet.Transaction),
utxos: make(map[types.SiacoinOutputID]wallet.SiacoinElement),
}
}