diff --git a/go.mod b/go.mod index 859f9d8..ec6cc1f 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,8 @@ toolchain go1.22.3 require ( github.com/mattn/go-sqlite3 v1.14.22 - go.sia.tech/core v0.3.0 - go.sia.tech/coreutils v0.1.0 + go.sia.tech/core v0.4.0 + go.sia.tech/coreutils v0.2.0 go.sia.tech/jape v0.12.0 go.sia.tech/web/walletd v0.22.3 go.uber.org/zap v1.27.0 @@ -25,7 +25,7 @@ require ( go.sia.tech/mux v1.2.0 // indirect go.sia.tech/web v0.0.0-20240610131903-5611d44a533e // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.24.0 // indirect + golang.org/x/crypto v0.25.0 // indirect golang.org/x/sys v0.22.0 // indirect golang.org/x/tools v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 3ab1a66..74b1f03 100644 --- a/go.sum +++ b/go.sum @@ -12,10 +12,10 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.etcd.io/bbolt v1.3.10 h1:+BqfJTcCzTItrop8mq/lbzL8wSGtj94UO/3U31shqG0= go.etcd.io/bbolt v1.3.10/go.mod h1:bK3UQLPJZly7IlNmV7uVHJDxfe5aK9Ll93e/74Y9oEQ= -go.sia.tech/core v0.3.0 h1:PDfAQh9z8PYD+oeVS7rS9SEnTMOZzwwFfAH45yktmko= -go.sia.tech/core v0.3.0/go.mod h1:BMgT/reXtgv6XbDgUYTCPY7wSMbspDRDs7KMi1vL6Iw= -go.sia.tech/coreutils v0.1.0 h1:WQL7iT+jK1BiMx87bASXrZJZf4N2fbQkIOW8rS7wkh4= -go.sia.tech/coreutils v0.1.0/go.mod h1:ybaFgewKXrlxFW71LqsyQlxjG6yWL6BSePrbZYnrprU= +go.sia.tech/core v0.4.0 h1:TlbVuiw1nk7wAybSvuZozRixnI4lpmcK0MVIlpJ9ApA= +go.sia.tech/core v0.4.0/go.mod h1:6dN3J2GDX+f8H2p82MJ7V4BFdnmgoHAiovfmBD/F1Hg= +go.sia.tech/coreutils v0.2.0 h1:Tad3SPPyUM0gW/jwCxMFFgOa6YSkcwH0dsUv6muB4NA= +go.sia.tech/coreutils v0.2.0/go.mod h1:WpdAhWmtQ8gyqJfXnHhWLsnWn+j1eDkkWuvFVMDG4IU= go.sia.tech/jape v0.12.0 h1:13fBi7c5X8zxTQ05Cd9ZsIfRJgdvGoZqbEzH861z7BU= go.sia.tech/jape v0.12.0/go.mod h1:wU+h6Wh5olDjkPXjF0tbZ1GDgoZ6VTi4naFw91yyWC4= go.sia.tech/mux v1.2.0 h1:ofa1Us9mdymBbGMY2XH/lSpY8itFsKIo/Aq8zwe+GHU= @@ -30,8 +30,8 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= diff --git a/persist/sqlite/peers.go b/persist/sqlite/peers.go index a626908..daca41f 100644 --- a/persist/sqlite/peers.go +++ b/persist/sqlite/peers.go @@ -207,16 +207,25 @@ func (s *Store) Banned(peer string) (banned bool, _ error) { } err = s.transaction(func(tx *txn) error { - query := `SELECT net_cidr, expiration FROM syncer_bans WHERE net_cidr IN (` + queryPlaceHolders(len(checkSubnets)) + `) ORDER BY expiration DESC LIMIT 1` - - var subnet string - var expiration time.Time - err := tx.QueryRow(query, queryArgs(checkSubnets)...).Scan(&subnet, decode(&expiration)) - banned = time.Now().Before(expiration) // will return false for any sql errors, including ErrNoRows - if err == nil && banned { - s.log.Debug("found ban", zap.String("subnet", subnet), zap.Time("expiration", expiration)) + checkSubnetStmt, err := tx.Prepare(`SELECT expiration FROM syncer_bans WHERE net_cidr = $1 ORDER BY expiration DESC LIMIT 1`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) } - return err + defer checkSubnetStmt.Close() + + for _, subnet := range checkSubnets { + var expiration time.Time + + err := checkSubnetStmt.QueryRow(subnet).Scan(decode(&expiration)) + banned = time.Now().Before(expiration) // will return false for any sql errors, including ErrNoRows + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("failed to check ban status: %w", err) + } else if banned { + s.log.Debug("found ban", zap.String("subnet", subnet), zap.Time("expiration", expiration)) + return nil + } + } + return nil }) if err != nil && !errors.Is(err, sql.ErrNoRows) { return false, fmt.Errorf("failed to check ban status: %w", err) diff --git a/persist/sqlite/sql.go b/persist/sqlite/sql.go index c9f990f..2ea2424 100644 --- a/persist/sqlite/sql.go +++ b/persist/sqlite/sql.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "math/rand" - "strings" "time" _ "github.com/mattn/go-sqlite3" // import sqlite3 driver @@ -171,32 +170,6 @@ func (tx *txn) QueryRow(query string, args ...any) *row { return &row{r, tx.log.Named("row")} } -func queryPlaceHolders(n int) string { - if n == 0 { - return "" - } else if n == 1 { - return "?" - } - var b strings.Builder - b.Grow(((n - 1) * 2) + 1) // ?,? - for i := 0; i < n-1; i++ { - b.WriteString("?,") - } - b.WriteString("?") - return b.String() -} - -func queryArgs[T any](args []T) []any { - if len(args) == 0 { - return nil - } - out := make([]any, len(args)) - for i, arg := range args { - out[i] = arg - } - return out -} - // getDBVersion returns the current version of the database. func getDBVersion(db *sql.DB) (version int64) { // error is ignored -- the database may not have been initialized yet. diff --git a/persist/sqlite/wallet.go b/persist/sqlite/wallet.go index 6204848..95564b4 100644 --- a/persist/sqlite/wallet.go +++ b/persist/sqlite/wallet.go @@ -12,27 +12,42 @@ import ( ) func (s *Store) getWalletEventRelevantAddresses(tx *txn, id wallet.ID, eventIDs []int64) (map[int64][]types.Address, error) { - query := `SELECT ea.event_id, sa.sia_address + stmt, err := tx.Prepare(`SELECT sa.sia_address FROM event_addresses ea INNER JOIN sia_addresses sa ON (ea.address_id = sa.id) -WHERE event_id IN (` + queryPlaceHolders(len(eventIDs)) + `) AND address_id IN (SELECT address_id FROM wallet_addresses WHERE wallet_id=?)` - - rows, err := tx.Query(query, append(queryArgs(eventIDs), id)...) +INNER JOIN wallet_addresses wa ON (ea.address_id = wa.address_id) +WHERE wa.wallet_id=? AND ea.event_id=?`) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + relevant := func(walletID wallet.ID, eventID int64) (addresses []types.Address, err error) { + rows, err := stmt.Query(walletID, eventID) + if err != nil { + return nil, fmt.Errorf("failed to query relevant addresses: %w", err) + } + defer rows.Close() + + for rows.Next() { + var address types.Address + if err := rows.Scan(decode(&address)); err != nil { + return nil, fmt.Errorf("failed to scan relevant address: %w", err) + } + addresses = append(addresses, address) + } + return addresses, rows.Err() } - defer rows.Close() relevantAddresses := make(map[int64][]types.Address) - for rows.Next() { - var eventID int64 - var address types.Address - if err := rows.Scan(&eventID, decode(&address)); err != nil { - return nil, fmt.Errorf("failed to scan relevant address: %w", err) + for _, eventID := range eventIDs { + addresses, err := relevant(id, eventID) + if err != nil { + return nil, err } - relevantAddresses[eventID] = append(relevantAddresses[eventID], address) + relevantAddresses[eventID] = addresses } - return relevantAddresses, rows.Err() + return relevantAddresses, nil } // WalletEvents returns the events relevant to a wallet, sorted by height descending. @@ -657,7 +672,7 @@ func getWalletEvents(tx *txn, id wallet.ID, offset, limit int) (events []wallet. } const eventsQuery = `SELECT ev.id, ev.event_id, ev.maturity_height, ev.date_created, ci.height, ci.block_id, ev.event_type, ev.event_data - FROM events ev INDEXED BY events_maturity_height_id_idx -- force the index to prevent temp-btree sorts + FROM events ev INNER JOIN event_addresses ea ON (ev.id = ea.event_id) INNER JOIN wallet_addresses wa ON (ea.address_id = wa.address_id) INNER JOIN chain_indices ci ON (ev.chain_index_id = ci.id) diff --git a/wallet/events.go b/wallet/events.go index 3151a0e..a42ab7e 100644 --- a/wallet/events.go +++ b/wallet/events.go @@ -71,9 +71,9 @@ type ( // An EventV2ContractResolution represents a file contract payout from a v2 // contract. EventV2ContractResolution struct { - types.V2FileContractResolution - SiacoinElement types.SiacoinElement `json:"siacoinElement"` - Missed bool `json:"missed"` + Resolution types.V2FileContractResolution `json:"resolution"` + SiacoinElement types.SiacoinElement `json:"siacoinElement"` + Missed bool `json:"missed"` } ) diff --git a/wallet/wallet.go b/wallet/wallet.go index 00d267f..4a8678a 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -191,10 +191,10 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f addresses[sfe.SiafundOutput.Address] = true } - outputID := sfi.ParentID.ClaimOutputID() - if sfo, ok := sces[outputID]; ok && relevant(sfi.ClaimAddress) { - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeSiafundClaim, EventPayout{ - SiacoinElement: sfo, + sce, ok := sces[sfi.ParentID.ClaimOutputID()] + if ok && relevant(sce.SiacoinOutput.Address) { + addEvent(sce.ID, sce.MaturityHeight, EventTypeSiafundClaim, EventPayout{ + SiacoinElement: sce, }, []types.Address{sfi.ClaimAddress}) } } @@ -238,10 +238,10 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f } addresses[sfi.Parent.SiafundOutput.Address] = true - outputID := types.SiafundOutputID(sfi.Parent.ID).V2ClaimOutputID() - if sfo, ok := sces[outputID]; ok && relevant(sfi.ClaimAddress) { - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeSiafundClaim, EventPayout{ - SiacoinElement: sfo, + sce, ok := sces[types.SiafundOutputID(sfi.Parent.ID).V2ClaimOutputID()] + if ok && relevant(sfi.ClaimAddress) { + addEvent(sce.ID, sce.MaturityHeight, EventTypeSiafundClaim, EventPayout{ + SiacoinElement: sce, }, []types.Address{sfi.ClaimAddress}) } } @@ -265,7 +265,7 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f addEvent(types.Hash256(txn.ID()), cs.Index.Height, EventTypeV2Transaction, ev, relevant) // transaction maturity height is the current block height } - // handle missed contracts + // handle contracts cu.ForEachFileContractElement(func(fce types.FileContractElement, _ bool, rev *types.FileContractElement, resolved, valid bool) { if !resolved { return @@ -278,10 +278,10 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f continue } - outputID := types.FileContractID(fce.ID).ValidOutputID(i) - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeV1ContractResolution, EventV1ContractResolution{ + element := sces[types.FileContractID(fce.ID).ValidOutputID(i)] + addEvent(element.ID, element.MaturityHeight, EventTypeV1ContractResolution, EventV1ContractResolution{ Parent: fce, - SiacoinElement: sces[outputID], + SiacoinElement: element, Missed: false, }, []types.Address{address}) } @@ -292,10 +292,10 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f continue } - outputID := types.FileContractID(fce.ID).MissedOutputID(i) - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeV1ContractResolution, EventV1ContractResolution{ + element := sces[types.FileContractID(fce.ID).MissedOutputID(i)] + addEvent(element.ID, element.MaturityHeight, EventTypeV1ContractResolution, EventV1ContractResolution{ Parent: fce, - SiacoinElement: sces[outputID], + SiacoinElement: element, Missed: true, }, []types.Address{address}) } @@ -313,25 +313,25 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f } if relevant(fce.V2FileContract.HostOutput.Address) { - outputID := types.FileContractID(fce.ID).V2HostOutputID() - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeV2ContractResolution, EventV2ContractResolution{ - V2FileContractResolution: types.V2FileContractResolution{ + element := sces[types.FileContractID(fce.ID).V2HostOutputID()] + addEvent(element.ID, element.MaturityHeight, EventTypeV2ContractResolution, EventV2ContractResolution{ + Resolution: types.V2FileContractResolution{ Parent: fce, Resolution: res, }, - SiacoinElement: sces[outputID], + SiacoinElement: element, Missed: missed, }, []types.Address{fce.V2FileContract.HostOutput.Address}) } if relevant(fce.V2FileContract.RenterOutput.Address) { - outputID := types.FileContractID(fce.ID).V2RenterOutputID() - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeV2ContractResolution, EventV2ContractResolution{ - V2FileContractResolution: types.V2FileContractResolution{ + element := sces[types.FileContractID(fce.ID).V2RenterOutputID()] + addEvent(element.ID, element.MaturityHeight, EventTypeV2ContractResolution, EventV2ContractResolution{ + Resolution: types.V2FileContractResolution{ Parent: fce, Resolution: res, }, - SiacoinElement: sces[outputID], + SiacoinElement: element, Missed: missed, }, []types.Address{fce.V2FileContract.RenterOutput.Address}) } @@ -340,21 +340,20 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate, relevant f // handle block rewards for i := range b.MinerPayouts { if relevant(b.MinerPayouts[i].Address) { - outputID := cs.Index.ID.MinerOutputID(i) - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeMinerPayout, EventPayout{ - SiacoinElement: sces[outputID], + element := sces[cs.Index.ID.MinerOutputID(i)] + addEvent(element.ID, element.MaturityHeight, EventTypeMinerPayout, EventPayout{ + SiacoinElement: element, }, []types.Address{b.MinerPayouts[i].Address}) } } // handle foundation subsidy if relevant(cs.FoundationPrimaryAddress) { - outputID := cs.Index.ID.FoundationOutputID() - sce, ok := sces[outputID] + element, ok := sces[cs.Index.ID.FoundationOutputID()] if ok { - addEvent(types.Hash256(outputID), cs.MaturityHeight(), EventTypeFoundationSubsidy, EventPayout{ - SiacoinElement: sce, - }, []types.Address{sce.SiacoinOutput.Address}) + addEvent(element.ID, element.MaturityHeight, EventTypeFoundationSubsidy, EventPayout{ + SiacoinElement: element, + }, []types.Address{element.SiacoinOutput.Address}) } } diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index 66485d2..1f6914d 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -5,8 +5,11 @@ import ( "context" "encoding/json" "fmt" + "math" + "math/bits" "path/filepath" "reflect" + "sort" "testing" "time" @@ -17,6 +20,7 @@ import ( "go.sia.tech/coreutils/testutil" "go.sia.tech/walletd/persist/sqlite" "go.sia.tech/walletd/wallet" + "go.uber.org/zap" "go.uber.org/zap/zaptest" ) @@ -2821,3 +2825,807 @@ func TestDeleteWallet(t *testing.T) { t.Fatal(err) } } + +// NOTE: due to a bug in the transaction validation code, calculating payouts +// is way harder than it needs to be. Tax is calculated on the post-tax +// contract payout (instead of the sum of the renter and host payouts). So the +// equation for the payout is: +// +// payout = renterPayout + hostPayout + payout*tax +// ∴ payout = (renterPayout + hostPayout) / (1 - tax) +// +// This would work if 'tax' were a simple fraction, but because the tax must +// be evenly distributed among siafund holders, 'tax' is actually a function +// that multiplies by a fraction and then rounds down to the nearest multiple +// of the siafund count. Thus, when inverting the function, we have to make an +// initial guess and then fix the rounding error. +func taxAdjustedPayout(target types.Currency) types.Currency { + // compute initial guess as target * (1 / 1-tax); since this does not take + // the siafund rounding into account, the guess will be up to + // types.SiafundCount greater than the actual payout value. + guess := target.Mul64(1000).Div64(961) + + // now, adjust the guess to remove the rounding error. We know that: + // + // (target % types.SiafundCount) == (payout % types.SiafundCount) + // + // therefore, we can simply adjust the guess to have this remainder as + // well. The only wrinkle is that, since we know guess >= payout, if the + // guess remainder is smaller than the target remainder, we must subtract + // an extra types.SiafundCount. + // + // for example, if target = 87654321 and types.SiafundCount = 10000, then: + // + // initial_guess = 87654321 * (1 / (1 - tax)) + // = 91211572 + // target % 10000 = 4321 + // adjusted_guess = 91204321 + + mod64 := func(c types.Currency, v uint64) types.Currency { + var r uint64 + if c.Hi < v { + _, r = bits.Div64(c.Hi, c.Lo, v) + } else { + _, r = bits.Div64(0, c.Hi, v) + _, r = bits.Div64(r, c.Lo, v) + } + return types.NewCurrency64(r) + } + sfc := (consensus.State{}).SiafundCount() + tm := mod64(target, sfc) + gm := mod64(guess, sfc) + if gm.Cmp(tm) < 0 { + guess = guess.Sub(types.NewCurrency64(sfc)) + } + return guess.Add(tm).Sub(gm) +} + +func TestEventTypes(t *testing.T) { + pk := types.GeneratePrivateKey() + addr := types.StandardUnlockHash(pk.PublicKey()) + + log := zap.NewNop() + dir := t.TempDir() + db, err := sqlite.OpenDatabase(filepath.Join(dir, "walletd.sqlite3"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) + if err != nil { + t.Fatal(err) + } + defer bdb.Close() + + // create a new test network with the Siafund airdrop going to the wallet address + network, genesisBlock := testV2Network(addr) + // raise the require height to test v1 events + network.HardforkV2.RequireHeight = 250 + store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock) + if err != nil { + t.Fatal(err) + } + cm := chain.NewManager(store, genesisState) + + // helper to mine blocks + mineBlock := func(n int, addr types.Address) { + t.Helper() + for i := 0; i < n; i++ { + b, ok := coreutils.MineBlock(cm, addr, 15*time.Second) + if !ok { + t.Fatal("failed to mine block") + } else if err := cm.AddBlocks([]types.Block{b}); err != nil { + t.Fatal(err) + } + } + waitForBlock(t, cm, db) + } + + wm, err := wallet.NewManager(cm, db, wallet.WithLogger(log.Named("wallet")), wallet.WithIndexMode(wallet.IndexModeFull)) + if err != nil { + t.Fatal(err) + } + defer wm.Close() + + spendableSiacoinUTXOs := func() []types.SiacoinElement { + t.Helper() + + sces, err := wm.AddressSiacoinOutputs(addr, 0, 100) + if err != nil { + t.Fatal(err) + } + filtered := sces[:0] + height := cm.Tip().Height + for _, sce := range sces { + if sce.MaturityHeight > height { + continue + } + filtered = append(filtered, sce) + } + sort.Slice(filtered, func(i, j int) bool { + return filtered[i].SiacoinOutput.Value.Cmp(filtered[j].SiacoinOutput.Value) < 0 + }) + return filtered + } + + assertEvent := func(id types.Hash256, eventType string, expectedInflow, expectedOutflow types.Currency, maturityHeight uint64) { + t.Helper() + + events, err := wm.AddressEvents(addr, 0, 100) + if err != nil { + t.Fatal(err) + } + + for _, event := range events { + if event.ID == id { + if event.Type != eventType { + t.Fatalf("expected %v event, got %v", eventType, event.Type) + } else if event.MaturityHeight != maturityHeight { + t.Fatalf("expected maturity height %v, got %v", maturityHeight, event.MaturityHeight) + } + + var inflowSum, outflowSum types.Currency + switch ev := event.Data.(type) { + case wallet.EventV1Transaction: + for _, sce := range ev.SpentSiacoinElements { + if sce.SiacoinOutput.Address == addr { + outflowSum = outflowSum.Add(sce.SiacoinOutput.Value) + } + } + for _, sce := range ev.Transaction.SiacoinOutputs { + if sce.Address == addr { + inflowSum = inflowSum.Add(sce.Value) + } + } + case wallet.EventV1ContractResolution: + if ev.SiacoinElement.SiacoinOutput.Address == addr { + inflowSum = ev.SiacoinElement.SiacoinOutput.Value + } + case wallet.EventPayout: + if ev.SiacoinElement.SiacoinOutput.Address == addr { + inflowSum = ev.SiacoinElement.SiacoinOutput.Value + } + case wallet.EventV2ContractResolution: + if ev.SiacoinElement.SiacoinOutput.Address == addr { + inflowSum = ev.SiacoinElement.SiacoinOutput.Value + } + case wallet.EventV2Transaction: + for _, sce := range ev.SiacoinInputs { + if sce.Parent.SiacoinOutput.Address == addr { + outflowSum = outflowSum.Add(sce.Parent.SiacoinOutput.Value) + } + } + for _, sce := range ev.SiacoinOutputs { + if sce.Address == addr { + inflowSum = inflowSum.Add(sce.Value) + } + } + default: + t.Fatalf("unexpected event type %T", ev) + } + + if !inflowSum.Equals(expectedInflow) { + t.Fatalf("expected inflow %v, got %v", expectedInflow, inflowSum) + } else if !outflowSum.Equals(expectedOutflow) { + t.Fatalf("expected outflow %v, got %v", expectedOutflow, outflowSum) + } + return + } + } + t.Fatalf("event not found") + } + + // miner payout event + mineBlock(1, addr) + assertEvent(types.Hash256(cm.Tip().ID.MinerOutputID(0)), wallet.EventTypeMinerPayout, genesisState.BlockReward(), types.ZeroCurrency, genesisState.MaturityHeight()) + + // mine until the payout matures + mineBlock(int(cm.TipState().MaturityHeight()), types.VoidAddress) + + // v1 transaction + t.Run("v1 transaction", func(t *testing.T) { + sce := spendableSiacoinUTXOs() + + // v1 only supports unlock conditions + uc := types.StandardUnlockConditions(pk.PublicKey()) + + // create a transaction + txn := types.Transaction{ + SiacoinInputs: []types.SiacoinInput{ + {ParentID: types.SiacoinOutputID(sce[0].ID), UnlockConditions: uc}, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: types.Siacoins(1000)}, + {Address: addr, Value: sce[0].SiacoinOutput.Value.Sub(types.Siacoins(1000))}, + }, + Signatures: []types.TransactionSignature{ + { + ParentID: sce[0].ID, + PublicKeyIndex: 0, + Timelock: 0, + CoveredFields: types.CoveredFields{WholeTransaction: true}, + }, + }, + } + + // sign the transaction + sigHash := cm.TipState().WholeSigHash(txn, sce[0].ID, 0, 0, nil) + sig := pk.SignHash(sigHash) + txn.Signatures[0].Signature = sig[:] + + // broadcast the transaction + if _, err := cm.AddPoolTransactions([]types.Transaction{txn}); err != nil { + t.Fatal(err) + } + // mine a block to confirm the transaction + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(txn.ID()), wallet.EventTypeV1Transaction, sce[0].SiacoinOutput.Value.Sub(types.Siacoins(1000)), sce[0].SiacoinOutput.Value, cm.Tip().Height) + }) + + t.Run("v1 contract resolution - missed", func(t *testing.T) { + // v1 contract resolution - only one type of resolution is supported. + // The only difference is `missed == true` or `missed == false` + + sce := spendableSiacoinUTXOs() + uc := types.StandardUnlockConditions(pk.PublicKey()) + + // create a storage contract + contractPayout := types.Siacoins(10000) + fc := types.FileContract{ + WindowStart: cm.TipState().Index.Height + 10, + WindowEnd: cm.TipState().Index.Height + 20, + Payout: taxAdjustedPayout(contractPayout), + ValidProofOutputs: []types.SiacoinOutput{ + {Address: addr, Value: contractPayout}, + }, + MissedProofOutputs: []types.SiacoinOutput{ + {Address: addr, Value: contractPayout}, + }, + } + + // create a transaction with the contract + txn := types.Transaction{ + SiacoinInputs: []types.SiacoinInput{ + {ParentID: types.SiacoinOutputID(sce[0].ID), UnlockConditions: uc}, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: addr, Value: sce[0].SiacoinOutput.Value.Sub(fc.Payout)}, // return the remainder to the wallet + }, + FileContracts: []types.FileContract{fc}, + Signatures: []types.TransactionSignature{ + { + ParentID: sce[0].ID, + PublicKeyIndex: 0, + Timelock: 0, + CoveredFields: types.CoveredFields{WholeTransaction: true}, + }, + }, + } + sigHash := cm.TipState().WholeSigHash(txn, sce[0].ID, 0, 0, nil) + sig := pk.SignHash(sigHash) + txn.Signatures[0].Signature = sig[:] + + // broadcast the transaction + if _, err := cm.AddPoolTransactions([]types.Transaction{txn}); err != nil { + t.Fatal(err) + } + + txn.FileContractID(0).MissedOutputID(0) + + // mine a block to confirm the transaction + mineBlock(1, types.VoidAddress) + // mine until the contract expires to trigger the resolution event + blocksRemaining := int(fc.WindowEnd - cm.Tip().Height) + mineBlock(blocksRemaining, types.VoidAddress) + assertEvent(types.Hash256(txn.FileContractID(0).MissedOutputID(0)), wallet.EventTypeV1ContractResolution, contractPayout, types.ZeroCurrency, fc.WindowEnd+144) + }) + + t.Run("v2 transaction", func(t *testing.T) { + sce := spendableSiacoinUTXOs() + + // using the UnlockConditions policy for brevity + policy := types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk.PublicKey())), + } + + txn := types.V2Transaction{ + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: sce[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: types.VoidAddress, Value: types.Siacoins(1000)}, + {Address: addr, Value: sce[0].SiacoinOutput.Value.Sub(types.Siacoins(1000))}, + }, + } + sigHash := cm.TipState().InputSigHash(txn) + txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(sigHash)} + + // broadcast the transaction + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{txn}); err != nil { + t.Fatal(err) + } + // mine a block to confirm the transaction + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(txn.ID()), wallet.EventTypeV2Transaction, sce[0].SiacoinOutput.Value.Sub(types.Siacoins(1000)), sce[0].SiacoinOutput.Value, cm.Tip().Height) + }) + + t.Run("v2 contract resolution - expired", func(t *testing.T) { + sce := spendableSiacoinUTXOs() + + // using the UnlockConditions policy for brevity + policy := types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk.PublicKey())), + } + + // create a storage contract + renterPayout := types.Siacoins(10000) + fc := types.V2FileContract{ + RenterOutput: types.SiacoinOutput{ + Address: addr, + Value: renterPayout, + }, + HostOutput: types.SiacoinOutput{ + Address: types.VoidAddress, + Value: types.ZeroCurrency, + }, + ProofHeight: cm.TipState().Index.Height + 10, + ExpirationHeight: cm.TipState().Index.Height + 20, + + RenterPublicKey: pk.PublicKey(), + HostPublicKey: pk.PublicKey(), + } + contractValue := renterPayout.Add(cm.TipState().V2FileContractTax(fc)) + sigHash := cm.TipState().ContractSigHash(fc) + sig := pk.SignHash(sigHash) + fc.RenterSignature = sig + fc.HostSignature = sig + + // create a transaction with the contract + txn := types.V2Transaction{ + FileContracts: []types.V2FileContract{fc}, + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: sce[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: addr, Value: sce[0].SiacoinOutput.Value.Sub(contractValue)}, + }, + } + sigHash = cm.TipState().InputSigHash(txn) + txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(sigHash)} + + // broadcast the transaction + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{txn}); err != nil { + t.Fatal(err) + } + // current tip + tip := cm.Tip() + // mine until the contract expires + mineBlock(int(fc.ExpirationHeight-cm.Tip().Height), types.VoidAddress) + + // this is kind of annoying because we have to keep the file contract + // proof up to date. + _, applied, err := cm.UpdatesSince(tip, 1000) + if err != nil { + t.Fatal(err) + } + + // get the confirmed file contract element + var fce types.V2FileContractElement + applied[0].ForEachV2FileContractElement(func(ele types.V2FileContractElement, _ bool, _ *types.V2FileContractElement, _ types.V2FileContractResolutionType) { + fce = ele + }) + for _, cau := range applied { + cau.UpdateElementProof(&fce.StateElement) + } + + resolutionTxn := types.V2Transaction{ + FileContractResolutions: []types.V2FileContractResolution{ + { + Parent: fce, + Resolution: &types.V2FileContractExpiration{}, + }, + }, + } + // broadcast the expire resolution + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{resolutionTxn}); err != nil { + t.Fatal(err) + } + // mine a block to confirm the resolution + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(types.FileContractID(fce.ID).V2RenterOutputID()), wallet.EventTypeV2ContractResolution, renterPayout, types.ZeroCurrency, cm.Tip().Height+144) + }) + + t.Run("v2 contract resolution - storage proof", func(t *testing.T) { + sce := spendableSiacoinUTXOs() + + // using the UnlockConditions policy for brevity + policy := types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk.PublicKey())), + } + + // create a storage contract + renterPayout := types.Siacoins(10000) + fc := types.V2FileContract{ + RenterOutput: types.SiacoinOutput{ + Address: types.VoidAddress, + Value: types.ZeroCurrency, + }, + HostOutput: types.SiacoinOutput{ + Address: addr, + Value: renterPayout, + }, + ProofHeight: cm.TipState().Index.Height + 10, + ExpirationHeight: cm.TipState().Index.Height + 20, + + RenterPublicKey: pk.PublicKey(), + HostPublicKey: pk.PublicKey(), + } + contractValue := renterPayout.Add(cm.TipState().V2FileContractTax(fc)) + sigHash := cm.TipState().ContractSigHash(fc) + sig := pk.SignHash(sigHash) + fc.RenterSignature = sig + fc.HostSignature = sig + + // create a transaction with the contract + txn := types.V2Transaction{ + FileContracts: []types.V2FileContract{fc}, + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: sce[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: addr, Value: sce[0].SiacoinOutput.Value.Sub(contractValue)}, + }, + } + sigHash = cm.TipState().InputSigHash(txn) + txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(sigHash)} + + // broadcast the transaction + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{txn}); err != nil { + t.Fatal(err) + } + // current tip + tip := cm.Tip() + // mine until the contract proof window + mineBlock(int(fc.ProofHeight-cm.Tip().Height), types.VoidAddress) + + // this is even more annoying because we have to keep the file contract + // proof and the chain index proof up to date. + _, applied, err := cm.UpdatesSince(tip, 1000) + if err != nil { + t.Fatal(err) + } + + // get the confirmed file contract element + var fce types.V2FileContractElement + applied[0].ForEachV2FileContractElement(func(ele types.V2FileContractElement, _ bool, _ *types.V2FileContractElement, _ types.V2FileContractResolutionType) { + fce = ele + }) + // update its proof + for _, cau := range applied { + cau.UpdateElementProof(&fce.StateElement) + } + // get the proof index element + indexElement := applied[len(applied)-1].ChainIndexElement() + + resolutionTxn := types.V2Transaction{ + FileContractResolutions: []types.V2FileContractResolution{ + { + Parent: fce, + Resolution: &types.V2StorageProof{ + ProofIndex: indexElement, + // proof is nil since there's no data + }, + }, + }, + } + + // broadcast the expire resolution + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{resolutionTxn}); err != nil { + t.Fatal(err) + } + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(types.FileContractID(fce.ID).V2HostOutputID()), wallet.EventTypeV2ContractResolution, renterPayout, types.ZeroCurrency, cm.Tip().Height+144) + }) + + t.Run("v2 contract resolution - renewal", func(t *testing.T) { + sces := spendableSiacoinUTXOs() + + // using the UnlockConditions policy for brevity + policy := types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk.PublicKey())), + } + + // create a storage contract + renterPayout := types.Siacoins(10000) + fc := types.V2FileContract{ + RenterOutput: types.SiacoinOutput{ + Address: addr, + Value: renterPayout, + }, + HostOutput: types.SiacoinOutput{ + Address: types.VoidAddress, + Value: types.ZeroCurrency, + }, + ProofHeight: cm.TipState().Index.Height + 10, + ExpirationHeight: cm.TipState().Index.Height + 20, + + RenterPublicKey: pk.PublicKey(), + HostPublicKey: pk.PublicKey(), + } + contractValue := renterPayout.Add(cm.TipState().V2FileContractTax(fc)) + sigHash := cm.TipState().ContractSigHash(fc) + sig := pk.SignHash(sigHash) + fc.RenterSignature = sig + fc.HostSignature = sig + + // create a transaction with the contract + txn := types.V2Transaction{ + FileContracts: []types.V2FileContract{fc}, + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: sces[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: addr, Value: sces[0].SiacoinOutput.Value.Sub(contractValue)}, + }, + } + sigHash = cm.TipState().InputSigHash(txn) + txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(sigHash)} + + // broadcast the transaction + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{txn}); err != nil { + t.Fatal(err) + } + // current tip + tip := cm.Tip() + // mine until the contract proof window + mineBlock(1, types.VoidAddress) + + // this is even more annoying because we have to keep the file contract + // proof and the chain index proof up to date. + _, applied, err := cm.UpdatesSince(tip, 1000) + if err != nil { + t.Fatal(err) + } + + // get the confirmed file contract element + var fce types.V2FileContractElement + applied[0].ForEachV2FileContractElement(func(ele types.V2FileContractElement, _ bool, _ *types.V2FileContractElement, _ types.V2FileContractResolutionType) { + fce = ele + }) + for _, cau := range applied { + cau.UpdateElementProof(&fce.StateElement) + } + + // finalize the contract + finalRevision := fce.V2FileContract + finalRevision.RevisionNumber = math.MaxUint64 + finalRevision.RenterSignature = types.Signature{} + finalRevision.HostSignature = types.Signature{} + // create a renewal + renewal := types.V2FileContractRenewal{ + FinalRevision: finalRevision, + NewContract: types.V2FileContract{ + RenterOutput: fc.RenterOutput, + ProofHeight: fc.ProofHeight + 10, + ExpirationHeight: fc.ExpirationHeight + 10, + + RenterPublicKey: fc.RenterPublicKey, + HostPublicKey: fc.HostPublicKey, + }, + } + + renewalSigHash := cm.TipState().RenewalSigHash(renewal) + renewalSig := pk.SignHash(renewalSigHash) + renewal.RenterSignature = renewalSig + renewal.HostSignature = renewalSig + + sces = spendableSiacoinUTXOs() + newContractValue := renterPayout.Add(cm.TipState().V2FileContractTax(renewal.NewContract)) + + // renewals can't have change outputs + setupTxn := types.V2Transaction{ + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: sces[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: addr, Value: newContractValue}, + {Address: addr, Value: sces[0].SiacoinOutput.Value.Sub(newContractValue)}, + }, + } + setupSigHash := cm.TipState().InputSigHash(setupTxn) + setupTxn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(setupSigHash)} + + // create the renewal transaction + resolutionTxn := types.V2Transaction{ + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: setupTxn.EphemeralSiacoinOutput(0), + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + FileContractResolutions: []types.V2FileContractResolution{ + { + Parent: fce, + Resolution: &renewal, + }, + }, + } + resolutionTxnSigHash := cm.TipState().InputSigHash(resolutionTxn) + resolutionTxn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(resolutionTxnSigHash)} + + // broadcast the renewal + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{setupTxn, resolutionTxn}); err != nil { + t.Fatal(err) + } + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(types.FileContractID(fce.ID).V2RenterOutputID()), wallet.EventTypeV2ContractResolution, renterPayout, types.ZeroCurrency, cm.Tip().Height+144) + }) + + t.Run("v2 contract resolution - finalization", func(t *testing.T) { + t.Skip("finalization currently errors with commitment hash mismatch") + + sces := spendableSiacoinUTXOs() + + // using the UnlockConditions policy for brevity + policy := types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk.PublicKey())), + } + + // create a storage contract + renterPayout := types.Siacoins(10000) + fc := types.V2FileContract{ + RenterOutput: types.SiacoinOutput{ + Address: addr, + Value: renterPayout, + }, + HostOutput: types.SiacoinOutput{ + Address: types.VoidAddress, + Value: types.ZeroCurrency, + }, + ProofHeight: cm.TipState().Index.Height + 10, + ExpirationHeight: cm.TipState().Index.Height + 20, + + RenterPublicKey: pk.PublicKey(), + HostPublicKey: pk.PublicKey(), + } + contractValue := renterPayout.Add(cm.TipState().V2FileContractTax(fc)) + sigHash := cm.TipState().ContractSigHash(fc) + sig := pk.SignHash(sigHash) + fc.RenterSignature = sig + fc.HostSignature = sig + + // create a transaction with the contract + txn := types.V2Transaction{ + FileContracts: []types.V2FileContract{fc}, + SiacoinInputs: []types.V2SiacoinInput{ + { + Parent: sces[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + }, + }, + SiacoinOutputs: []types.SiacoinOutput{ + {Address: addr, Value: sces[0].SiacoinOutput.Value.Sub(contractValue)}, + }, + } + sigHash = cm.TipState().InputSigHash(txn) + txn.SiacoinInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(sigHash)} + + // broadcast the transaction + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{txn}); err != nil { + t.Fatal(err) + } + // current tip + tip := cm.Tip() + // mine until the contract proof window + mineBlock(1, types.VoidAddress) + + // this is even more annoying because we have to keep the file contract + // proof and the chain index proof up to date. + _, applied, err := cm.UpdatesSince(tip, 1000) + if err != nil { + t.Fatal(err) + } + + // get the confirmed file contract element + var fce types.V2FileContractElement + applied[0].ForEachV2FileContractElement(func(ele types.V2FileContractElement, _ bool, _ *types.V2FileContractElement, _ types.V2FileContractResolutionType) { + fce = ele + }) + for _, cau := range applied { + cau.UpdateElementProof(&fce.StateElement) + } + + // finalize the contract + fc = fce.V2FileContract + fc.RevisionNumber = types.MaxRevisionNumber + finalizationSigHash := cm.TipState().ContractSigHash(fc) + fc.RenterSignature = pk.SignHash(finalizationSigHash) + fc.HostSignature = pk.SignHash(finalizationSigHash) + finalization := types.V2FileContractFinalization(fc) + + // create the resolution transaction + finalizationTxn := types.V2Transaction{ + FileContractResolutions: []types.V2FileContractResolution{ + { + Parent: fce, + Resolution: &finalization, + }, + }, + } + + // broadcast the resolution + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{finalizationTxn}); err != nil { + t.Fatal(err) + } + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(types.FileContractID(fce.ID).V2RenterOutputID()), wallet.EventTypeV2ContractResolution, renterPayout, types.ZeroCurrency, cm.Tip().Height+144) + }) + + t.Run("siafund claim", func(t *testing.T) { + sfe, err := wm.AddressSiafundOutputs(addr, 0, 100) + if err != nil { + t.Fatal(err) + } + + policy := types.SpendPolicy{ + Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk.PublicKey())), + } + + // create a transaction + txn := types.V2Transaction{ + SiafundInputs: []types.V2SiafundInput{ + { + Parent: sfe[0], + SatisfiedPolicy: types.SatisfiedPolicy{ + Policy: policy, + }, + ClaimAddress: addr, + }, + }, + SiafundOutputs: []types.SiafundOutput{ + {Address: addr, Value: sfe[0].SiafundOutput.Value}, + }, + } + sigHash := cm.TipState().InputSigHash(txn) + txn.SiafundInputs[0].SatisfiedPolicy.Signatures = []types.Signature{pk.SignHash(sigHash)} + claimValue := cm.TipState().SiafundPool + + // broadcast the transaction + if _, err := cm.AddV2PoolTransactions(cm.Tip(), []types.V2Transaction{txn}); err != nil { + t.Fatal(err) + } + // mine a block to confirm the transaction + mineBlock(1, types.VoidAddress) + assertEvent(types.Hash256(types.SiafundOutputID(sfe[0].ID).V2ClaimOutputID()), wallet.EventTypeSiafundClaim, claimValue, types.ZeroCurrency, cm.Tip().Height+144) + }) +}