diff --git a/explorer/events.go b/explorer/events.go index 4c4f0a5..ad3321e 100644 --- a/explorer/events.go +++ b/explorer/events.go @@ -1,108 +1,47 @@ package explorer import ( - "time" - "go.sia.tech/core/consensus" "go.sia.tech/core/types" - "go.sia.tech/coreutils/chain" + "go.sia.tech/coreutils/wallet" ) -// event type constants +// event types indicate the source of an event. Events can +// either be created by sending Siacoins between addresses or they can be +// created by consensus (e.g. a miner payout, a siafund claim, or a contract). const ( - EventTypeTransaction = "transaction" - EventTypeV2Transaction = "v2transaction" - EventTypeMinerPayout = "miner payout" - EventTypeContractPayout = "contract payout" - EventTypeSiafundClaim = "siafund claim" - EventTypeFoundationSubsidy = "foundation subsidy" -) - -// Arbitrary data specifiers -var ( - SpecifierAnnouncement = types.NewSpecifier("HostAnnouncement") -) - -type eventData interface { - EventType() string -} - -// An Event is something interesting that happened on the Sia blockchain. -type Event struct { - ID types.Hash256 `json:"id"` - Index types.ChainIndex `json:"index"` - Timestamp time.Time `json:"timestamp"` - MaturityHeight uint64 `json:"maturityHeight"` - Addresses []types.Address `json:"addresses"` - Data eventData `json:"data"` -} - -// EventType implements Event. -func (*EventTransaction) EventType() string { return EventTypeTransaction } - -// EventType implements Event. -func (*EventV2Transaction) EventType() string { return EventTypeV2Transaction } - -// EventType implements Event. -func (*EventMinerPayout) EventType() string { return EventTypeMinerPayout } - -// EventType implements Event. -func (*EventFoundationSubsidy) EventType() string { return EventTypeFoundationSubsidy } - -// EventType implements Event. -func (*EventContractPayout) EventType() string { return EventTypeContractPayout } - -// An EventSiafundInput represents a siafund input within an EventTransaction. -type EventSiafundInput struct { - SiafundElement types.SiafundElement `json:"siafundElement"` - ClaimElement types.SiacoinElement `json:"claimElement"` -} - -// An EventFileContract represents a file contract within an EventTransaction. -type EventFileContract struct { - FileContract types.FileContractElement `json:"fileContract"` - // only non-nil if transaction revised contract - Revision *types.FileContract `json:"revision,omitempty"` - // only non-nil if transaction resolved contract - ValidOutputs []types.SiacoinElement `json:"validOutputs,omitempty"` -} - -// An EventV2FileContract represents a v2 file contract within an EventTransaction. -type EventV2FileContract struct { - FileContract types.V2FileContractElement `json:"fileContract"` - // only non-nil if transaction revised contract - Revision *types.V2FileContract `json:"revision,omitempty"` - // only non-nil if transaction resolved contract - Resolution types.V2FileContractResolutionType `json:"resolution,omitempty"` - Outputs []types.SiacoinElement `json:"outputs,omitempty"` -} + EventTypeMinerPayout = wallet.EventTypeMinerPayout + EventTypeFoundationSubsidy = wallet.EventTypeFoundationSubsidy + EventTypeSiafundClaim = wallet.EventTypeSiafundClaim -// An EventTransaction represents a transaction that affects the wallet. -type EventTransaction struct { - Transaction Transaction `json:"transaction"` - HostAnnouncements []chain.HostAnnouncement `json:"hostAnnouncements"` - Fee types.Currency `json:"fee"` -} - -// An EventV2Transaction represents a v2 transaction that affects the wallet. -type EventV2Transaction V2Transaction + EventTypeV1Transaction = wallet.EventTypeV1Transaction + EventTypeV1ContractResolution = wallet.EventTypeV1ContractResolution -// An EventMinerPayout represents a miner payout from a block. -type EventMinerPayout struct { - SiacoinOutput types.SiacoinElement `json:"siacoinOutput"` -} - -// EventFoundationSubsidy represents a foundation subsidy from a block. -type EventFoundationSubsidy struct { - SiacoinOutput types.SiacoinElement `json:"siacoinOutput"` -} + EventTypeV2Transaction = wallet.EventTypeV2Transaction + EventTypeV2ContractResolution = wallet.EventTypeV2ContractResolution +) -// An EventContractPayout represents a file contract payout -type EventContractPayout struct { - FileContract types.FileContractElement `json:"fileContract"` - SiacoinOutput types.SiacoinElement `json:"siacoinOutput"` - Missed bool `json:"missed"` -} +type ( + // An EventPayout represents a miner payout, siafund claim, or foundation + // subsidy. + EventPayout = wallet.EventPayout + // An EventV1Transaction pairs a v1 transaction with its spent siacoin and + // siafund elements. + EventV1Transaction = wallet.EventV1Transaction + // An EventV1ContractResolution represents a file contract payout from a v1 + // contract. + EventV1ContractResolution = wallet.EventV1ContractResolution + // EventV2Transaction is a transaction event that includes the transaction + EventV2Transaction = wallet.EventV2Transaction + // An EventV2ContractResolution represents a file contract payout from a v2 + // contract. + EventV2ContractResolution = wallet.EventV2ContractResolution + + // EventData is the data associated with an event. + EventData = wallet.EventData + // An Event is a record of a consensus event that affects the wallet. + Event = wallet.Event +) // A ChainUpdate is a set of changes to the consensus state. type ChainUpdate interface { @@ -113,13 +52,12 @@ type ChainUpdate interface { } // AppliedEvents extracts a list of relevant events from a chain update. -func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate) []Event { - var events []Event - addEvent := func(id types.Hash256, maturityHeight uint64, v eventData, addresses []types.Address) { +func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate) (events []Event) { + addEvent := func(id types.Hash256, maturityHeight uint64, eventType string, v EventData, relevant []types.Address) { // dedup relevant addresses seen := make(map[types.Address]bool) - unique := addresses[:0] - for _, addr := range addresses { + unique := relevant[:0] + for _, addr := range relevant { if !seen[addr] { unique = append(unique, addr) seen[addr] = true @@ -131,7 +69,8 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate) []Event { Timestamp: b.Timestamp, Index: cs.Index, MaturityHeight: maturityHeight, - Addresses: unique, + Relevant: unique, + Type: eventType, Data: v, }) } @@ -139,136 +78,192 @@ func AppliedEvents(cs consensus.State, b types.Block, cu ChainUpdate) []Event { // collect all elements sces := make(map[types.SiacoinOutputID]types.SiacoinElement) sfes := make(map[types.SiafundOutputID]types.SiafundElement) - fces := make(map[types.FileContractID]types.FileContractElement) - v2fces := make(map[types.FileContractID]types.V2FileContractElement) - cu.ForEachSiacoinElement(func(sce types.SiacoinElement, created, spent bool) { + cu.ForEachSiacoinElement(func(sce types.SiacoinElement, _, _ bool) { sce.StateElement.MerkleProof = nil - sces[sce.ID] = sce + sces[types.SiacoinOutputID(sce.ID)] = sce }) - cu.ForEachSiafundElement(func(sfe types.SiafundElement, created, spent bool) { + cu.ForEachSiafundElement(func(sfe types.SiafundElement, _, _ bool) { sfe.StateElement.MerkleProof = nil - sfes[sfe.ID] = sfe - }) - cu.ForEachFileContractElement(func(fce types.FileContractElement, created bool, rev *types.FileContractElement, resolved, valid bool) { - fce.StateElement.MerkleProof = nil - fces[fce.ID] = fce - }) - cu.ForEachV2FileContractElement(func(fce types.V2FileContractElement, created bool, rev *types.V2FileContractElement, res types.V2FileContractResolutionType) { - fce.StateElement.MerkleProof = nil - v2fces[fce.ID] = fce + sfes[types.SiafundOutputID(sfe.ID)] = sfe }) - relevantTxn := func(txn types.Transaction) (addrs []types.Address) { - for _, sci := range txn.SiacoinInputs { - addrs = append(addrs, sces[sci.ParentID].SiacoinOutput.Address) - } - for _, sco := range txn.SiacoinOutputs { - addrs = append(addrs, sco.Address) - } - for _, sfi := range txn.SiafundInputs { - addrs = append(addrs, sfes[sfi.ParentID].SiafundOutput.Address) - } - for _, sfo := range txn.SiafundOutputs { - addrs = append(addrs, sfo.Address) + // handle v1 transactions + for _, txn := range b.Transactions { + addresses := make(map[types.Address]struct{}) + e := EventV1Transaction{ + Transaction: txn, + SpentSiacoinElements: make([]types.SiacoinElement, 0, len(txn.SiacoinInputs)), + SpentSiafundElements: make([]types.SiafundElement, 0, len(txn.SiafundInputs)), } - return - } - relevantV2Txn := func(txn types.V2Transaction) (addrs []types.Address) { for _, sci := range txn.SiacoinInputs { - addrs = append(addrs, sci.Parent.SiacoinOutput.Address) + sce, ok := sces[sci.ParentID] + if !ok { + continue + } + + e.SpentSiacoinElements = append(e.SpentSiacoinElements, sce) + addresses[sce.SiacoinOutput.Address] = struct{}{} } for _, sco := range txn.SiacoinOutputs { - addrs = append(addrs, sco.Address) + addresses[sco.Address] = struct{}{} } + for _, sfi := range txn.SiafundInputs { - addrs = append(addrs, sfi.Parent.SiafundOutput.Address) + sfe, ok := sfes[sfi.ParentID] + if !ok { + continue + } + + e.SpentSiafundElements = append(e.SpentSiafundElements, sfe) + addresses[sfe.SiafundOutput.Address] = struct{}{} + + sce, ok := sces[sfi.ParentID.ClaimOutputID()] + if ok { + addEvent(types.Hash256(sce.ID), sce.MaturityHeight, EventTypeSiafundClaim, EventPayout{ + SiacoinElement: sce, + }, []types.Address{sfi.ClaimAddress}) + } } for _, sfo := range txn.SiafundOutputs { - addrs = append(addrs, sfo.Address) + addresses[sfo.Address] = struct{}{} } - return - } - // handle v1 transactions - for _, txn := range b.Transactions { - relevant := relevantTxn(txn) - - var e EventTransaction - for _, arb := range txn.ArbitraryData { - var ha chain.HostAnnouncement - if ha.FromArbitraryData(arb) { - e.HostAnnouncements = append(e.HostAnnouncements, ha) + for _, fc := range txn.FileContracts { + addresses[fc.UnlockHash] = struct{}{} + for _, vpo := range fc.ValidProofOutputs { + addresses[vpo.Address] = struct{}{} + } + for _, mpo := range fc.MissedProofOutputs { + addresses[mpo.Address] = struct{}{} } } + // skip transactions with no relevant addresses + if len(addresses) == 0 { + continue + } - for i := range txn.MinerFees { - e.Fee = e.Fee.Add(txn.MinerFees[i]) + relevant := make([]types.Address, 0, len(addresses)) + for addr := range addresses { + relevant = append(relevant, addr) } - addEvent(types.Hash256(txn.ID()), cs.Index.Height, &e, relevant) // transaction maturity height is the current block height + addEvent(types.Hash256(txn.ID()), cs.Index.Height, EventTypeV1Transaction, e, relevant) // transaction maturity height is the current block height } // handle v2 transactions for _, txn := range b.V2Transactions() { - relevant := relevantV2Txn(txn) - - var e EventV2Transaction - for _, a := range txn.Attestations { - var ha chain.V2HostAnnouncement - if ha.FromAttestation(a) == nil { - e.HostAnnouncements = append(e.HostAnnouncements, V2HostAnnouncement{ - PublicKey: a.PublicKey, - V2HostAnnouncement: ha, - }) + addresses := make(map[types.Address]struct{}) + for _, sci := range txn.SiacoinInputs { + addresses[sci.Parent.SiacoinOutput.Address] = struct{}{} + } + for _, sco := range txn.SiacoinOutputs { + addresses[sco.Address] = struct{}{} + } + for _, sfi := range txn.SiafundInputs { + addresses[sfi.Parent.SiafundOutput.Address] = struct{}{} + + sce, ok := sces[types.SiafundOutputID(sfi.Parent.ID).V2ClaimOutputID()] + if ok { + addEvent(types.Hash256(sce.ID), sce.MaturityHeight, EventTypeSiafundClaim, EventPayout{ + SiacoinElement: sce, + }, []types.Address{sfi.ClaimAddress}) } } + for _, sco := range txn.SiafundOutputs { + addresses[sco.Address] = struct{}{} + } - addEvent(types.Hash256(txn.ID()), cs.Index.Height, &e, relevant) // transaction maturity height is the current block height + ev := EventV2Transaction(txn) + relevant := make([]types.Address, 0, len(addresses)) + for addr := range addresses { + relevant = append(relevant, addr) + } + addEvent(types.Hash256(txn.ID()), cs.Index.Height, EventTypeV2Transaction, ev, relevant) // transaction maturity height is the current block height } - // handle missed contracts - cu.ForEachFileContractElement(func(fce types.FileContractElement, created bool, rev *types.FileContractElement, resolved, valid bool) { + // handle contracts + cu.ForEachFileContractElement(func(fce types.FileContractElement, _ bool, rev *types.FileContractElement, resolved, valid bool) { if !resolved { return } + fce.StateElement.MerkleProof = nil + if valid { for i := range fce.FileContract.ValidProofOutputs { - outputID := fce.ID.ValidOutputID(i) - addEvent(types.Hash256(outputID), cs.MaturityHeight(), &EventContractPayout{ - FileContract: fce, - SiacoinOutput: sces[outputID], - Missed: false, - }, []types.Address{fce.FileContract.ValidProofOutputs[i].Address}) + address := fce.FileContract.ValidProofOutputs[i].Address + element := sces[types.FileContractID(fce.ID).ValidOutputID(i)] + addEvent(types.Hash256(element.ID), element.MaturityHeight, EventTypeV1ContractResolution, EventV1ContractResolution{ + Parent: fce, + SiacoinElement: element, + Missed: false, + }, []types.Address{address}) } } else { for i := range fce.FileContract.MissedProofOutputs { - outputID := fce.ID.MissedOutputID(i) - addEvent(types.Hash256(outputID), cs.MaturityHeight(), &EventContractPayout{ - FileContract: fce, - SiacoinOutput: sces[outputID], - Missed: true, - }, []types.Address{fce.FileContract.MissedProofOutputs[i].Address}) + address := fce.FileContract.MissedProofOutputs[i].Address + element := sces[types.FileContractID(fce.ID).MissedOutputID(i)] + addEvent(types.Hash256(element.ID), element.MaturityHeight, EventTypeV1ContractResolution, EventV1ContractResolution{ + Parent: fce, + SiacoinElement: element, + Missed: true, + }, []types.Address{address}) } } }) + cu.ForEachV2FileContractElement(func(fce types.V2FileContractElement, _ bool, rev *types.V2FileContractElement, res types.V2FileContractResolutionType) { + if res == nil { + return + } + + fce.StateElement.MerkleProof = nil + + var missed bool + if _, ok := res.(*types.V2FileContractExpiration); ok { + missed = true + } + + { + element := sces[types.FileContractID(fce.ID).V2HostOutputID()] + addEvent(types.Hash256(element.ID), element.MaturityHeight, EventTypeV2ContractResolution, EventV2ContractResolution{ + Resolution: types.V2FileContractResolution{ + Parent: fce, + Resolution: res, + }, + SiacoinElement: element, + Missed: missed, + }, []types.Address{fce.V2FileContract.HostOutput.Address}) + } + + { + element := sces[types.FileContractID(fce.ID).V2RenterOutputID()] + addEvent(types.Hash256(element.ID), element.MaturityHeight, EventTypeV2ContractResolution, EventV2ContractResolution{ + Resolution: types.V2FileContractResolution{ + Parent: fce, + Resolution: res, + }, + SiacoinElement: element, + Missed: missed, + }, []types.Address{fce.V2FileContract.RenterOutput.Address}) + } + }) + // handle block rewards for i := range b.MinerPayouts { - outputID := cs.Index.ID.MinerOutputID(i) - addEvent(types.Hash256(outputID), cs.MaturityHeight(), &EventMinerPayout{ - SiacoinOutput: sces[outputID], + element := sces[cs.Index.ID.MinerOutputID(i)] + addEvent(types.Hash256(element.ID), element.MaturityHeight, EventTypeMinerPayout, EventPayout{ + SiacoinElement: element, }, []types.Address{b.MinerPayouts[i].Address}) } // handle foundation subsidy - outputID := cs.Index.ID.FoundationOutputID() - sce, ok := sces[outputID] + element, ok := sces[cs.Index.ID.FoundationOutputID()] if ok { - addEvent(types.Hash256(outputID), cs.MaturityHeight(), &EventFoundationSubsidy{ - SiacoinOutput: sce, - }, []types.Address{cs.FoundationSubsidyAddress}) + addEvent(types.Hash256(element.ID), element.MaturityHeight, EventTypeFoundationSubsidy, EventPayout{ + SiacoinElement: element, + }, []types.Address{element.SiacoinOutput.Address}) } return events diff --git a/explorer/types.go b/explorer/types.go index eec17fa..104c76d 100644 --- a/explorer/types.go +++ b/explorer/types.go @@ -225,8 +225,7 @@ type V2FileContractRenewal struct { } // A V2FileContractResolution closes a v2 file contract's payment channel. -// There are four resolution types: renewwal, storage proof, finalization, -// and expiration. +// There are three resolution types: renewwal, storage proof, and expiration. type V2FileContractResolution struct { Parent V2FileContract `json:"parent"` Type string `json:"string"` diff --git a/go.mod b/go.mod index 97045cb..3d13ff8 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( ) require ( + github.com/google/go-cmp v0.6.0 // indirect github.com/julienschmidt/httprouter v1.3.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/rogpeppe/go-internal v1.10.0 // indirect diff --git a/go.sum b/go.sum index 436db51..ed08e80 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/ip2location/ip2location-go v8.3.0+incompatible h1:QwUE+FlSbo6bjOWZpv2Grb57vJhWYFNPyBj2KCvfWaM= github.com/ip2location/ip2location-go v8.3.0+incompatible/go.mod h1:3JUY1TBjTx1GdA7oRT7Zeqfc0bg3lMMuU5lXmzdpuME= github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= diff --git a/internal/testutil/check.go b/internal/testutil/check.go index 13fa049..eba7a32 100644 --- a/internal/testutil/check.go +++ b/internal/testutil/check.go @@ -4,6 +4,9 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "go.sia.tech/core/consensus" "go.sia.tech/core/types" "go.sia.tech/coreutils/chain" "go.sia.tech/explored/explorer" @@ -13,8 +16,8 @@ import ( func Equal[T any](t *testing.T, desc string, expect, got T) { t.Helper() - if !reflect.DeepEqual(expect, got) { - t.Fatalf("expected %v %s, got %v", expect, desc, got) + if !cmp.Equal(expect, got, cmpopts.EquateEmpty(), cmpopts.IgnoreUnexported(consensus.Work{}), cmpopts.IgnoreTypes(types.StateElement{}, "MerkleProof")) { + t.Fatalf("%s expected != got, diff: %s", desc, cmp.Diff(expect, got)) } } diff --git a/persist/sqlite/addresses.go b/persist/sqlite/addresses.go index 760c908..8e8c31d 100644 --- a/persist/sqlite/addresses.go +++ b/persist/sqlite/addresses.go @@ -10,74 +10,50 @@ import ( "go.sia.tech/explored/explorer" ) -func scanEvent(tx *txn, s scanner) (ev explorer.Event, eventID int64, err error) { - var eventType string - - err = s.Scan(&eventID, decode(&ev.ID), &ev.MaturityHeight, decode(&ev.Timestamp), &ev.Index.Height, decode(&ev.Index.ID), &eventType) - if err != nil { - return - } +// AddressEvents returns the events of a single address. +func (s *Store) AddressEvents(address types.Address, offset, limit uint64) (events []explorer.Event, err error) { + err = s.transaction(func(tx *txn) error { + const query = ` +WITH last_chain_index (height) AS ( + SELECT MAX(height) FROM blocks +) +SELECT + ev.id, + ev.event_id, + ev.maturity_height, + ev.date_created, + b.height, + b.id, + CASE + WHEN last_chain_index.height < b.height THEN 0 + ELSE last_chain_index.height - b.height + END AS confirmations, + ev.event_type +FROM events ev INDEXED BY events_maturity_height_id_idx -- force the index to prevent temp-btree sorts +INNER JOIN event_addresses ea ON (ev.id = ea.event_id) +INNER JOIN address_balance sa ON (ea.address_id = sa.id) +INNER JOIN blocks b ON (ev.block_id = b.id) +CROSS JOIN last_chain_index +WHERE sa.address = $1 +ORDER BY ev.maturity_height DESC, ev.id DESC +LIMIT $2 OFFSET $3` - switch eventType { - case explorer.EventTypeTransaction: - var txnID int64 - var eventTx explorer.EventTransaction - err = tx.QueryRow(`SELECT transaction_id, fee FROM transaction_events WHERE event_id = ?`, eventID).Scan(&txnID, decode(&eventTx.Fee)) - if err != nil { - return explorer.Event{}, 0, fmt.Errorf("failed to fetch transaction ID: %w", err) - } - txns, err := getTransactions(tx, map[int64]transactionID{0: {dbID: txnID, id: types.TransactionID(ev.ID)}}) - if err != nil || len(txns) == 0 { - return explorer.Event{}, 0, fmt.Errorf("failed to fetch transaction: %w", err) - } - eventTx.Transaction = txns[0] - eventTx.HostAnnouncements = eventTx.Transaction.HostAnnouncements - ev.Data = &eventTx - case explorer.EventTypeV2Transaction: - var txnID int64 - err = tx.QueryRow(`SELECT transaction_id FROM v2_transaction_events WHERE event_id = ?`, eventID).Scan(&txnID) - if err != nil { - return explorer.Event{}, 0, fmt.Errorf("failed to fetch v2 transaction ID: %w", err) - } - txns, err := getV2Transactions(tx, []types.TransactionID{types.TransactionID(ev.ID)}) - if err != nil || len(txns) == 0 { - return explorer.Event{}, 0, fmt.Errorf("failed to fetch v2 transaction: %w", err) - } - eventTx := explorer.EventV2Transaction(txns[0]) - ev.Data = &eventTx - case explorer.EventTypeContractPayout: - var m explorer.EventContractPayout - err = tx.QueryRow(`SELECT sce.output_id, sce.leaf_index, sce.maturity_height, sce.address, sce.value, fce.contract_id, fce.leaf_index, fce.filesize, fce.file_merkle_root, fce.window_start, fce.window_end, fce.payout, fce.unlock_hash, fce.revision_number, ev.missed -FROM contract_payout_events ev -JOIN siacoin_elements sce ON ev.output_id = sce.id -JOIN file_contract_elements fce ON ev.contract_id = fce.id -WHERE ev.event_id = ?`, eventID).Scan(decode(&m.SiacoinOutput.ID), decode(&m.SiacoinOutput.StateElement.LeafIndex), &m.SiacoinOutput.MaturityHeight, decode(&m.SiacoinOutput.SiacoinOutput.Address), decode(&m.SiacoinOutput.SiacoinOutput.Value), decode(&m.FileContract.ID), decode(&m.FileContract.StateElement.LeafIndex), decode(&m.FileContract.FileContract.Filesize), decode(&m.FileContract.FileContract.FileMerkleRoot), decode(&m.FileContract.FileContract.WindowStart), decode(&m.FileContract.FileContract.WindowEnd), decode(&m.FileContract.FileContract.Payout), decode(&m.FileContract.FileContract.UnlockHash), decode(&m.FileContract.FileContract.RevisionNumber), &m.Missed) - ev.Data = &m - case explorer.EventTypeMinerPayout: - var m explorer.EventMinerPayout - err = tx.QueryRow(`SELECT sc.output_id, sc.leaf_index, sc.maturity_height, sc.address, sc.value -FROM siacoin_elements sc -INNER JOIN miner_payout_events ev ON ev.output_id = sc.id -WHERE ev.event_id = ?`, eventID).Scan(decode(&m.SiacoinOutput.ID), decode(&m.SiacoinOutput.StateElement.LeafIndex), decode(&m.SiacoinOutput.MaturityHeight), decode(&m.SiacoinOutput.SiacoinOutput.Address), decode(&m.SiacoinOutput.SiacoinOutput.Value)) + rows, err := tx.Query(query, encode(address), limit, offset) if err != nil { - return explorer.Event{}, 0, fmt.Errorf("failed to fetch miner payout event data: %w", err) + return err } - ev.Data = &m - case explorer.EventTypeFoundationSubsidy: - var m explorer.EventFoundationSubsidy - err = tx.QueryRow(`SELECT sc.output_id, sc.leaf_index, sc.maturity_height, sc.address, sc.value -FROM siacoin_elements sc -INNER JOIN foundation_subsidy_events ev ON ev.output_id = sc.id -WHERE ev.event_id = ?`, eventID).Scan(decode(&m.SiacoinOutput.ID), decode(&m.SiacoinOutput.StateElement.LeafIndex), decode(&m.SiacoinOutput.MaturityHeight), decode(&m.SiacoinOutput.SiacoinOutput.Address), decode(&m.SiacoinOutput.SiacoinOutput.Value)) - ev.Data = &m - default: - return explorer.Event{}, 0, fmt.Errorf("unknown event type: %s", eventType) - } - - if err != nil { - return explorer.Event{}, 0, fmt.Errorf("failed to fetch transaction event data: %w", err) - } + defer rows.Close() + for rows.Next() { + event, _, err := scanEvent(tx, rows) + if err != nil { + return fmt.Errorf("failed to scan event: %w", err) + } + event.Relevant = []types.Address{address} + events = append(events, event) + } + return rows.Err() + }) return } @@ -186,36 +162,6 @@ func (s *Store) HostsForScanning(maxLastScan, minLastAnnouncement time.Time, off return } -// AddressEvents returns the events of a single address. -func (s *Store) AddressEvents(address types.Address, offset, limit uint64) (events []explorer.Event, err error) { - err = s.transaction(func(tx *txn) error { - const query = `SELECT ev.id, ev.event_id, ev.maturity_height, ev.date_created, ev.height, ev.block_id, ev.event_type - FROM events ev - INNER JOIN event_addresses ea ON ev.id = ea.event_id - INNER JOIN address_balance sa ON ea.address_id = sa.id - WHERE sa.address = $1 - ORDER BY ev.maturity_height DESC, ev.id DESC - LIMIT $2 OFFSET $3` - - rows, err := tx.Query(query, encode(address), limit, offset) - if err != nil { - return err - } - defer rows.Close() - - for rows.Next() { - event, _, err := scanEvent(tx, rows) - if err != nil { - return fmt.Errorf("failed to scan event: %w", err) - } - - events = append(events, event) - } - return rows.Err() - }) - return -} - func scanSiacoinOutput(s scanner) (sco explorer.SiacoinOutput, err error) { var spentIndex types.ChainIndex err = s.Scan(decode(&sco.ID), decode(&sco.StateElement.LeafIndex), &sco.Source, decodeNull(&spentIndex), &sco.MaturityHeight, decode(&sco.SiacoinOutput.Address), decode(&sco.SiacoinOutput.Value)) diff --git a/persist/sqlite/consensus.go b/persist/sqlite/consensus.go index 8330cbd..20ad1d2 100644 --- a/persist/sqlite/consensus.go +++ b/persist/sqlite/consensus.go @@ -1,15 +1,15 @@ package sqlite import ( - "bytes" "database/sql" - "encoding/json" "errors" "fmt" + "reflect" "time" "go.sia.tech/core/types" "go.sia.tech/coreutils/chain" + "go.sia.tech/coreutils/wallet" "go.sia.tech/explored/explorer" ) @@ -620,18 +620,18 @@ func addSiafundElements(tx *txn, index types.ChainIndex, spentElements, newEleme return sfDBIds, nil } -func addEvents(tx *txn, scDBIds map[types.SiacoinOutputID]int64, fcDBIds map[explorer.DBFileContract]int64, txnDBIds map[types.TransactionID]txnDBId, v2TxnDBIds map[types.TransactionID]txnDBId, events []explorer.Event) error { +func addEvents(tx *txn, bid types.BlockID, scDBIds map[types.SiacoinOutputID]int64, sfDBIds map[types.SiafundOutputID]int64, fcDBIds map[explorer.DBFileContract]int64, v2FcDBIds map[explorer.DBFileContract]int64, txnDBIds map[types.TransactionID]txnDBId, v2TxnDBIds map[types.TransactionID]txnDBId, events []wallet.Event) error { if len(events) == 0 { return nil } - insertEventStmt, err := tx.Prepare(`INSERT INTO events (event_id, maturity_height, date_created, event_type, block_id, height) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (event_id) DO NOTHING RETURNING id`) + insertEventStmt, err := tx.Prepare(`INSERT INTO events (event_id, maturity_height, date_created, event_type, block_id) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (event_id) DO NOTHING RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare event statement: %w", err) } defer insertEventStmt.Close() - addrStmt, err := tx.Prepare(`INSERT INTO address_balance (address, siacoin_balance, immature_siacoin_balance, siafund_balance) VALUES ($1, $2, $3, 0) ON CONFLICT (address) DO UPDATE SET address=EXCLUDED.address RETURNING id`) + addrStmt, err := tx.Prepare(`INSERT INTO address_balance (address, siacoin_balance, immature_siacoin_balance, siafund_balance) VALUES ($1, $2, $2, 0) ON CONFLICT (address) DO UPDATE SET address=EXCLUDED.address RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare address statement: %w", err) } @@ -643,11 +643,11 @@ func addEvents(tx *txn, scDBIds map[types.SiacoinOutputID]int64, fcDBIds map[exp } defer relevantAddrStmt.Close() - transactionEventStmt, err := tx.Prepare(`INSERT INTO transaction_events (event_id, transaction_id, fee) VALUES (?, ?, ?)`) + v1TransactionEventStmt, err := tx.Prepare(`INSERT INTO v1_transaction_events (event_id, transaction_id) VALUES (?, ?)`) if err != nil { - return fmt.Errorf("failed to prepare transaction event statement: %w", err) + return fmt.Errorf("failed to prepare v1 transaction event statement: %w", err) } - defer transactionEventStmt.Close() + defer v1TransactionEventStmt.Close() v2TransactionEventStmt, err := tx.Prepare(`INSERT INTO v2_transaction_events (event_id, transaction_id) VALUES (?, ?)`) if err != nil { @@ -655,72 +655,41 @@ func addEvents(tx *txn, scDBIds map[types.SiacoinOutputID]int64, fcDBIds map[exp } defer v2TransactionEventStmt.Close() - minerPayoutEventStmt, err := tx.Prepare(`INSERT INTO miner_payout_events (event_id, output_id) VALUES (?, ?)`) + payoutEventStmt, err := tx.Prepare(`INSERT INTO payout_events (event_id, output_id) VALUES (?, ?)`) if err != nil { - return fmt.Errorf("failed to prepare miner payout event statement: %w", err) + return fmt.Errorf("failed to prepare minerpayout event statement: %w", err) } - defer minerPayoutEventStmt.Close() + defer payoutEventStmt.Close() - contractPayoutEventStmt, err := tx.Prepare(`INSERT INTO contract_payout_events (event_id, output_id, contract_id, missed) VALUES (?, ?, ?, ?)`) + v1ContractResolutionEventStmt, err := tx.Prepare(`INSERT INTO v1_contract_resolution_events (event_id, output_id, parent_id, missed) VALUES (?, ?, ?, ?)`) if err != nil { - return fmt.Errorf("failed to prepare contract payout event statement: %w", err) + return fmt.Errorf("failed to prepare v1 contract resolution event statement: %w", err) } - defer contractPayoutEventStmt.Close() + defer v1ContractResolutionEventStmt.Close() - foundationSubsidyEventStmt, err := tx.Prepare(`INSERT INTO foundation_subsidy_events (event_id, output_id) VALUES (?, ?)`) + v2ContractResolutionEventStmt, err := tx.Prepare(`INSERT INTO v2_contract_resolution_events (event_id, output_id, parent_id, missed) VALUES (?, ?, ?, ?)`) if err != nil { - return fmt.Errorf("failed to prepare foundation subsidy event statement: %w", err) + return fmt.Errorf("failed to prepare v2 contract resolution event statement: %w", err) } - defer foundationSubsidyEventStmt.Close() + defer v2ContractResolutionEventStmt.Close() - var buf bytes.Buffer - enc := json.NewEncoder(&buf) for _, event := range events { - buf.Reset() - if err := enc.Encode(event.Data); err != nil { - return fmt.Errorf("failed to encode event: %w", err) - } - var eventID int64 - err = insertEventStmt.QueryRow(encode(event.ID), event.MaturityHeight, encode(event.Timestamp), event.Data.EventType(), encode(event.Index.ID), event.Index.Height).Scan(&eventID) + err = insertEventStmt.QueryRow(encode(event.ID), event.MaturityHeight, encode(event.Timestamp), event.Type, encode(bid)).Scan(&eventID) if errors.Is(err, sql.ErrNoRows) { continue // skip if the event already exists } else if err != nil { return fmt.Errorf("failed to add event: %w", err) } - switch v := event.Data.(type) { - case *explorer.EventTransaction: - dbID := txnDBIds[types.TransactionID(event.ID)].id - if _, err = transactionEventStmt.Exec(eventID, dbID, encode(v.Fee)); err != nil { - return fmt.Errorf("failed to insert transaction event: %w", err) - } - case *explorer.EventV2Transaction: - dbID := v2TxnDBIds[types.TransactionID(event.ID)].id - if _, err = v2TransactionEventStmt.Exec(eventID, dbID); err != nil { - return fmt.Errorf("failed to insert transaction event: %w", err) - } - case *explorer.EventMinerPayout: - _, err = minerPayoutEventStmt.Exec(eventID, scDBIds[types.SiacoinOutputID(event.ID)]) - case *explorer.EventContractPayout: - _, err = contractPayoutEventStmt.Exec(eventID, scDBIds[v.SiacoinOutput.ID], fcDBIds[explorer.DBFileContract{ID: v.FileContract.ID, RevisionNumber: v.FileContract.FileContract.RevisionNumber}], v.Missed) - case *explorer.EventFoundationSubsidy: - _, err = foundationSubsidyEventStmt.Exec(eventID, scDBIds[types.SiacoinOutputID(event.ID)]) - default: - return errors.New("unknown event type") - } - if err != nil { - return fmt.Errorf("failed to insert %s event: %w", event.Data.EventType(), err) - } - used := make(map[types.Address]bool) - for _, addr := range event.Addresses { + for _, addr := range event.Relevant { if used[addr] { continue } var addressID int64 - err = addrStmt.QueryRow(encode(addr), encode(types.ZeroCurrency), encode(types.ZeroCurrency)).Scan(&addressID) + err = addrStmt.QueryRow(encode(addr), encode(types.ZeroCurrency)).Scan(&addressID) if err != nil { return fmt.Errorf("failed to get address: %w", err) } @@ -732,6 +701,30 @@ func addEvents(tx *txn, scDBIds map[types.SiacoinOutputID]int64, fcDBIds map[exp used[addr] = true } + + switch v := event.Data.(type) { + case wallet.EventV1Transaction: + dbID := txnDBIds[types.TransactionID(event.ID)].id + if _, err = v1TransactionEventStmt.Exec(eventID, dbID); err != nil { + return fmt.Errorf("failed to insert transaction event: %w", err) + } + case wallet.EventV2Transaction: + dbID := v2TxnDBIds[types.TransactionID(event.ID)].id + if _, err = v2TransactionEventStmt.Exec(eventID, dbID); err != nil { + return fmt.Errorf("failed to insert transaction event: %w", err) + } + case wallet.EventPayout: + _, err = payoutEventStmt.Exec(eventID, scDBIds[types.SiacoinOutputID(event.ID)]) + case wallet.EventV1ContractResolution: + _, err = v1ContractResolutionEventStmt.Exec(eventID, scDBIds[v.SiacoinElement.ID], fcDBIds[explorer.DBFileContract{ID: v.Parent.ID, RevisionNumber: v.Parent.FileContract.RevisionNumber}], v.Missed) + case wallet.EventV2ContractResolution: + _, err = v2ContractResolutionEventStmt.Exec(eventID, scDBIds[v.SiacoinElement.ID], v2FcDBIds[explorer.DBFileContract{ID: v.Resolution.Parent.ID, RevisionNumber: v.Resolution.Parent.V2FileContract.RevisionNumber}], v.Missed) + default: + return fmt.Errorf("unknown event type: %T", reflect.TypeOf(event.Data)) + } + if err != nil { + return fmt.Errorf("failed to insert %v event: %w", reflect.TypeOf(event.Data), err) + } } return nil } @@ -1062,12 +1055,12 @@ func (ut *updateTx) ApplyIndex(state explorer.UpdateState) error { return fmt.Errorf("ApplyIndex: failed to update metrics: %w", err) } else if err := addHostAnnouncements(ut.tx, state.Block.Timestamp, state.HostAnnouncements, state.V2HostAnnouncements); err != nil { return fmt.Errorf("ApplyIndex: failed to add host announcements: %w", err) - } else if err := addEvents(ut.tx, scDBIds, fcDBIds, txnDBIds, v2TxnDBIds, state.Events); err != nil { - return fmt.Errorf("ApplyIndex: failed to add events: %w", err) } else if err := updateFileContractIndices(ut.tx, false, state.Metrics.Index, state.FileContractElements); err != nil { return fmt.Errorf("ApplyIndex: failed to update file contract element indices: %w", err) } else if err := updateV2FileContractIndices(ut.tx, false, state.Metrics.Index, state.V2FileContractElements); err != nil { return fmt.Errorf("ApplyIndex: failed to update v2 file contract element indices: %w", err) + } else if err := addEvents(ut.tx, state.Block.ID(), scDBIds, sfDBIds, fcDBIds, v2FcDBIds, txnDBIds, v2TxnDBIds, state.Events); err != nil { + return fmt.Errorf("ApplyIndex: failed to add events: %w", err) } return nil diff --git a/persist/sqlite/consensus_test.go b/persist/sqlite/consensus_test.go index f5dffb7..9c6c30e 100644 --- a/persist/sqlite/consensus_test.go +++ b/persist/sqlite/consensus_test.go @@ -12,6 +12,7 @@ import ( "go.sia.tech/coreutils" "go.sia.tech/coreutils/chain" ctestutil "go.sia.tech/coreutils/testutil" + "go.sia.tech/coreutils/wallet" "go.sia.tech/explored/explorer" "go.sia.tech/explored/internal/testutil" "go.sia.tech/explored/persist/sqlite" @@ -498,6 +499,9 @@ func TestFileContract(t *testing.T) { pk1 := types.GeneratePrivateKey() addr1 := types.StandardUnlockHash(pk1.PublicKey()) + pk2 := types.GeneratePrivateKey() + addr2 := types.StandardUnlockHash(pk2.PublicKey()) + renterPrivateKey := types.GeneratePrivateKey() renterPublicKey := renterPrivateKey.PublicKey() @@ -514,7 +518,7 @@ func TestFileContract(t *testing.T) { windowStart := cm.Tip().Height + 10 windowEnd := windowStart + 10 - fc := testutil.PrepareContractFormation(renterPublicKey, hostPublicKey, types.Siacoins(1), types.Siacoins(1), windowStart, windowEnd, types.VoidAddress) + fc := testutil.PrepareContractFormation(renterPublicKey, hostPublicKey, types.Siacoins(1), types.Siacoins(1), windowStart, windowEnd, addr2) txn := types.Transaction{ SiacoinInputs: []types.SiacoinInput{{ ParentID: scOutputID, @@ -679,6 +683,22 @@ func TestFileContract(t *testing.T) { StorageUtilization: 0, }) + { + events, err := db.AddressEvents(addr2, 0, math.MaxInt64) + if err != nil { + t.Fatal(err) + } + testutil.Equal(t, "events", 2, len(events)) + + ev0 := events[0].Data.(wallet.EventV1ContractResolution) + testutil.Equal(t, "event 0 parent ID", fcID, ev0.Parent.ID) + testutil.Equal(t, "event 0 output ID", fcID.MissedOutputID(0), ev0.SiacoinElement.ID) + testutil.Equal(t, "event 0 missed", true, ev0.Missed) + + ev1 := events[1].Data.(wallet.EventV1Transaction) + testutil.Equal(t, "event 1", txn, ev1.Transaction) + } + { dbFCs, err := db.Contracts([]types.FileContractID{fcID}) if err != nil { @@ -1619,11 +1639,9 @@ func TestHostAnnouncement(t *testing.T) { if err != nil { t.Fatal(err) } - if v, ok := events[0].Data.(*explorer.EventTransaction); !ok { - t.Fatal("expected EventTransaction") - } else { - testutil.CheckTransaction(t, txn1, v.Transaction) - } + testutil.Equal(t, "events", 2, len(events)) + testutil.Equal(t, "event 0", txn1, events[0].Data.(wallet.EventV1Transaction).Transaction) + testutil.Equal(t, "event 1", genesisBlock.Transactions[0], events[1].Data.(wallet.EventV1Transaction).Transaction) } { @@ -2107,6 +2125,16 @@ func TestMultipleReorgFileContract(t *testing.T) { testutil.CheckFC(t, false, false, false, fc, txns[0].FileContracts[0]) } + { + events, err := db.AddressEvents(addr1, 0, math.MaxInt64) + if err != nil { + t.Fatal(err) + } + testutil.Equal(t, "events", 2, len(events)) + testutil.Equal(t, "event 0", txn, events[0].Data.(wallet.EventV1Transaction).Transaction) + testutil.Equal(t, "event 1", genesisBlock.Transactions[0], events[1].Data.(wallet.EventV1Transaction).Transaction) + } + uc := types.UnlockConditions{ PublicKeys: []types.UnlockKey{ renterPublicKey.UnlockKey(), @@ -2341,6 +2369,15 @@ func TestMultipleReorgFileContract(t *testing.T) { TotalHosts: 0, }) } + + { + events, err := db.AddressEvents(addr1, 0, math.MaxInt64) + if err != nil { + t.Fatal(err) + } + testutil.Equal(t, "events", 1, len(events)) + testutil.Equal(t, "event 0", genesisBlock.Transactions[0], events[0].Data.(wallet.EventV1Transaction).Transaction) + } } func TestMetricCirculatingSupply(t *testing.T) { diff --git a/persist/sqlite/events.go b/persist/sqlite/events.go new file mode 100644 index 0000000..9a7a967 --- /dev/null +++ b/persist/sqlite/events.go @@ -0,0 +1,273 @@ +package sqlite + +import ( + "database/sql" + "errors" + "fmt" + "reflect" + + "go.sia.tech/core/types" + "go.sia.tech/coreutils/wallet" + "go.sia.tech/explored/explorer" +) + +// Events returns the events with the given event IDs. If an event is not found, +// it is skipped. +func (s *Store) Events(eventIDs []types.Hash256) (events []explorer.Event, err error) { + err = s.transaction(func(tx *txn) error { + // sqlite doesn't have easy support for IN clauses, use a statement since + // the number of event IDs is likely to be small instead of dynamically + // building the query + const query = ` +WITH last_chain_index (height) AS ( + SELECT MAX(height) FROM blocks +) +SELECT + ev.id, + ev.event_id, + ev.maturity_height, + ev.date_created, + b.height, + b.id, + CASE + WHEN last_chain_index.height < b.height THEN 0 + ELSE last_chain_index.height - b.height + END AS confirmations, + ev.event_type +FROM events ev +INNER JOIN event_addresses ea ON (ev.id = ea.event_id) +INNER JOIN address_balance sa ON (ea.address_id = sa.id) +INNER JOIN blocks b ON (ev.block_id = b.id) +CROSS JOIN last_chain_index +WHERE ev.event_id = $1` + + stmt, err := tx.Prepare(query) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + events = make([]explorer.Event, 0, len(eventIDs)) + for _, id := range eventIDs { + event, _, err := scanEvent(tx, stmt.QueryRow(encode(id))) + if errors.Is(err, sql.ErrNoRows) { + continue + } else if err != nil { + return fmt.Errorf("failed to query transaction %q: %w", id, err) + } + events = append(events, event) + } + return nil + }) + return +} + +func explorerToTypesV2Resolution(e explorer.V2FileContractResolution) (fcr types.V2FileContractResolution) { + fcr.Parent = e.Parent.V2FileContractElement + + switch v := e.Resolution.(type) { + case *explorer.V2FileContractRenewal: + fcr.Resolution = &types.V2FileContractRenewal{ + FinalRenterOutput: v.FinalRenterOutput, + FinalHostOutput: v.FinalHostOutput, + RenterRollover: v.RenterRollover, + HostRollover: v.HostRollover, + NewContract: v.NewContract.V2FileContractElement.V2FileContract, + RenterSignature: v.RenterSignature, + HostSignature: v.HostSignature, + } + case *types.V2StorageProof: + fcr.Resolution = v + case *types.V2FileContractExpiration: + fcr.Resolution = v + default: + panic(fmt.Errorf("unexpected revision type: %v", reflect.TypeOf(v))) + } + return +} + +func explorerToEventV1Transaction(e explorer.Transaction) (ev wallet.EventV1Transaction) { + extendedFCToTypes := func(fc explorer.ExtendedFileContract) types.FileContract { + result := types.FileContract{ + Filesize: fc.Filesize, + FileMerkleRoot: fc.FileMerkleRoot, + WindowStart: fc.WindowStart, + WindowEnd: fc.WindowEnd, + Payout: fc.Payout, + UnlockHash: fc.UnlockHash, + RevisionNumber: fc.RevisionNumber, + } + for _, vpo := range fc.ValidProofOutputs { + result.ValidProofOutputs = append(result.ValidProofOutputs, vpo.SiacoinOutput) + } + for _, mpo := range fc.MissedProofOutputs { + result.MissedProofOutputs = append(result.MissedProofOutputs, mpo.SiacoinOutput) + } + return result + } + + txn := &ev.Transaction + for _, sci := range e.SiacoinInputs { + txn.SiacoinInputs = append(txn.SiacoinInputs, sci.SiacoinInput) + } + for _, sco := range e.SiacoinOutputs { + txn.SiacoinOutputs = append(txn.SiacoinOutputs, sco.SiacoinOutput) + } + for _, sfi := range e.SiafundInputs { + txn.SiafundInputs = append(txn.SiafundInputs, sfi.SiafundInput) + } + for _, sfo := range e.SiafundOutputs { + txn.SiafundOutputs = append(txn.SiafundOutputs, sfo.SiafundOutput) + } + for _, fc := range e.FileContracts { + txn.FileContracts = append(txn.FileContracts, extendedFCToTypes(fc)) + } + for _, fcr := range e.FileContractRevisions { + txn.FileContractRevisions = append(txn.FileContractRevisions, types.FileContractRevision{ + ParentID: fcr.ParentID, + UnlockConditions: fcr.UnlockConditions, + FileContract: extendedFCToTypes(fcr.ExtendedFileContract), + }) + } + for _, sp := range e.StorageProofs { + txn.StorageProofs = append(txn.StorageProofs, sp) + } + for _, fee := range e.MinerFees { + txn.MinerFees = append(txn.MinerFees, fee) + } + for _, arb := range e.ArbitraryData { + txn.ArbitraryData = append(txn.ArbitraryData, arb) + } + for _, sig := range e.Signatures { + txn.Signatures = append(txn.Signatures, sig) + } + + return +} + +func explorerToEventV2Transaction(e explorer.V2Transaction) (txn wallet.EventV2Transaction) { + for _, sci := range e.SiacoinInputs { + txn.SiacoinInputs = append(txn.SiacoinInputs, sci) + } + for _, sco := range e.SiacoinOutputs { + txn.SiacoinOutputs = append(txn.SiacoinOutputs, sco.SiacoinOutput) + } + for _, sfi := range e.SiafundInputs { + txn.SiafundInputs = append(txn.SiafundInputs, sfi) + } + for _, sfo := range e.SiafundOutputs { + txn.SiafundOutputs = append(txn.SiafundOutputs, sfo.SiafundOutput) + } + for _, fc := range e.FileContracts { + txn.FileContracts = append(txn.FileContracts, fc.V2FileContractElement.V2FileContract) + } + for _, fcr := range e.FileContractRevisions { + txn.FileContractRevisions = append(txn.FileContractRevisions, types.V2FileContractRevision{ + Parent: fcr.Parent.V2FileContractElement, + Revision: fcr.Revision.V2FileContractElement.V2FileContract, + }) + } + for _, fcr := range e.FileContractResolutions { + txn.FileContractResolutions = append(txn.FileContractResolutions, explorerToTypesV2Resolution(fcr)) + } + for _, a := range e.Attestations { + txn.Attestations = append(txn.Attestations, a) + } + for _, arb := range e.ArbitraryData { + txn.ArbitraryData = append(txn.ArbitraryData, arb) + } + txn.NewFoundationAddress = e.NewFoundationAddress + txn.MinerFee = e.MinerFee + + return +} + +func scanEvent(tx *txn, s scanner) (ev explorer.Event, eventID int64, err error) { + err = s.Scan(&eventID, decode(&ev.ID), &ev.MaturityHeight, decode(&ev.Timestamp), &ev.Index.Height, decode(&ev.Index.ID), &ev.Confirmations, &ev.Type) + if err != nil { + return + } + + switch ev.Type { + case wallet.EventTypeV1Transaction: + var txnID int64 + err = tx.QueryRow(`SELECT transaction_id FROM v1_transaction_events WHERE event_id = ?`, eventID).Scan(&txnID) + if err != nil { + return explorer.Event{}, 0, fmt.Errorf("failed to fetch v1 transaction ID: %w", err) + } + txns, err := getTransactions(tx, map[int64]transactionID{0: {dbID: txnID, id: types.TransactionID(ev.ID)}}) + if err != nil || len(txns) == 0 { + return explorer.Event{}, 0, fmt.Errorf("failed to fetch v1 transaction: %w", err) + } + ev.Data = explorerToEventV1Transaction(txns[0]) + case wallet.EventTypeV2Transaction: + txns, err := getV2Transactions(tx, []types.TransactionID{types.TransactionID(ev.ID)}) + if err != nil || len(txns) == 0 { + return explorer.Event{}, 0, fmt.Errorf("failed to fetch v2 transaction: %w", err) + } + ev.Data = explorerToEventV2Transaction(txns[0]) + case wallet.EventTypeV1ContractResolution: + var resolution wallet.EventV1ContractResolution + fce, sce := &resolution.Parent, &resolution.SiacoinElement + err := tx.QueryRow(`SELECT sce.output_id, sce.leaf_index, sce.maturity_height, sce.address, sce.value, fce.contract_id, fce.leaf_index, fce.filesize, fce.file_merkle_root, fce.window_start, fce.window_end, fce.payout, fce.unlock_hash, fce.revision_number, ev.missed + FROM v1_contract_resolution_events ev + JOIN siacoin_elements sce ON ev.output_id = sce.id + JOIN file_contract_elements fce ON ev.parent_id = fce.id + WHERE ev.event_id = ?`, eventID).Scan(decode(&sce.ID), decode(&sce.StateElement.LeafIndex), decode(&sce.MaturityHeight), decode(&sce.SiacoinOutput.Address), decode(&sce.SiacoinOutput.Value), decode(&fce.ID), decode(&fce.StateElement.LeafIndex), decode(&fce.FileContract.Filesize), decode(&fce.FileContract.FileMerkleRoot), decode(&fce.FileContract.WindowStart), decode(&fce.FileContract.WindowEnd), decode(&fce.FileContract.Payout), decode(&fce.FileContract.UnlockHash), decode(&fce.FileContract.RevisionNumber), &resolution.Missed) + if err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to retrieve v1 resolution event: %w", err) + } + ev.Data = resolution + case wallet.EventTypeV2ContractResolution: + var resolution wallet.EventV2ContractResolution + var parentContractID types.FileContractID + var resolutionTransactionID types.TransactionID + sce := &resolution.SiacoinElement + err := tx.QueryRow(`SELECT sce.output_id, sce.leaf_index, sce.maturity_height, sce.address, sce.value, rev.contract_id, rev.resolution_transaction_id, ev.missed + FROM v2_contract_resolution_events ev + JOIN siacoin_elements sce ON ev.output_id = sce.id + JOIN v2_file_contract_elements fce ON ev.parent_id = fce.id + JOIN v2_last_contract_revision rev ON fce.contract_id = rev.contract_id + WHERE ev.event_id = ?`, eventID).Scan(decode(&sce.ID), decode(&sce.StateElement.LeafIndex), decode(&sce.MaturityHeight), decode(&sce.SiacoinOutput.Address), decode(&sce.SiacoinOutput.Value), decode(&parentContractID), decode(&resolutionTransactionID), &resolution.Missed) + if err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to retrieve v2 resolution event: %w", err) + } + + resolutionTxns, err := getV2Transactions(tx, []types.TransactionID{resolutionTransactionID}) + if err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to get transaction with v2 resolution: %w", err) + } else if len(resolutionTxns) == 0 { + return wallet.Event{}, 0, fmt.Errorf("v2 resolution transaction not found") + } + txn := resolutionTxns[0] + + found := false + for _, fcr := range txn.FileContractResolutions { + if fcr.Parent.ID == parentContractID { + found = true + resolution.Resolution = explorerToTypesV2Resolution(fcr) + break + } + } + if !found { + return wallet.Event{}, 0, fmt.Errorf("failed to find resolution in v2 resolution transaction") + } + + ev.Data = resolution + case wallet.EventTypeSiafundClaim, wallet.EventTypeMinerPayout, wallet.EventTypeFoundationSubsidy: + var payout wallet.EventPayout + sce := &payout.SiacoinElement + err := tx.QueryRow(`SELECT sce.output_id, sce.leaf_index, sce.maturity_height, sce.address, sce.value + FROM payout_events ev + JOIN siacoin_elements sce ON ev.output_id = sce.id + WHERE ev.event_id = ?`, eventID).Scan(decode(&sce.ID), decode(&sce.StateElement.LeafIndex), decode(&sce.MaturityHeight), decode(&sce.SiacoinOutput.Address), decode(&sce.SiacoinOutput.Value)) + if err != nil { + return wallet.Event{}, 0, fmt.Errorf("failed to retrieve payout event: %w", err) + } + ev.Data = payout + default: + return wallet.Event{}, 0, fmt.Errorf("unknown event type: %q", ev.Type) + } + + return +} diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index f9675b6..76d045f 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -381,44 +381,51 @@ CREATE TABLE state_tree ( CREATE TABLE events ( id INTEGER PRIMARY KEY, + block_id BLOB NOT NULL REFERENCES blocks(id) ON DELETE CASCADE, event_id BLOB UNIQUE NOT NULL, maturity_height INTEGER NOT NULL, date_created INTEGER NOT NULL, - event_type TEXT NOT NULL, - block_id BLOB NOT NULL REFERENCES blocks(id) ON DELETE CASCADE, -- add an index to all foreign keys - height INTEGER NOT NULL + event_type TEXT NOT NULL ); -CREATE INDEX events_block_id_height_index ON events(block_id, height); +CREATE INDEX events_block_id_idx ON events (block_id); +CREATE INDEX events_maturity_height_id_idx ON events (maturity_height DESC, id DESC); CREATE TABLE event_addresses ( - event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE, - address_id INTEGER NOT NULL REFERENCES address_balance(id), + event_id INTEGER NOT NULL REFERENCES events (id) ON DELETE CASCADE, + address_id INTEGER NOT NULL REFERENCES address_balance (id), PRIMARY KEY (event_id, address_id) ); -CREATE INDEX event_addresses_event_id_index ON event_addresses(event_id); -CREATE INDEX event_addresses_address_id_index ON event_addresses(address_id); +CREATE INDEX event_addresses_event_id_idx ON event_addresses (event_id); +CREATE INDEX event_addresses_address_id_idx ON event_addresses (address_id); +CREATE INDEX event_addresses_event_id_address_id_idx ON event_addresses (event_id, address_id); -CREATE TABLE transaction_events ( +CREATE TABLE v1_transaction_events ( event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, - transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE NOT NULL, - fee BLOB NOT NULL + transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE NOT NULL ); -CREATE TABLE contract_payout_events ( +CREATE TABLE v2_transaction_events ( event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, - output_id INTEGER REFERENCES siacoin_elements(id) ON DELETE CASCADE NOT NULL, - contract_id INTEGER REFERENCES file_contract_elements(id) ON DELETE CASCADE NOT NULL, - missed INTEGER NOT NULL + transaction_id INTEGER REFERENCES v2_transactions(id) ON DELETE CASCADE NOT NULL ); -CREATE TABLE miner_payout_events ( +CREATE TABLE payout_events ( event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, output_id INTEGER REFERENCES siacoin_elements(id) ON DELETE CASCADE NOT NULL ); -CREATE TABLE foundation_subsidy_events ( +CREATE TABLE v1_contract_resolution_events ( event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, - output_id INTEGER REFERENCES siacoin_elements(id) ON DELETE CASCADE NOT NULL + parent_id INTEGER REFERENCES file_contract_elements(id) ON DELETE CASCADE NOT NULL, + output_id INTEGER REFERENCES siacoin_elements(id) ON DELETE CASCADE NOT NULL, + missed INTEGER NOT NULL +); + +CREATE TABLE v2_contract_resolution_events ( + event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, + parent_id INTEGER REFERENCES v2_file_contract_elements(id) ON DELETE CASCADE NOT NULL, + output_id INTEGER REFERENCES siacoin_elements(id) ON DELETE CASCADE NOT NULL, + missed INTEGER NOT NULL ); CREATE TABLE v2_file_contract_elements ( @@ -465,11 +472,6 @@ CREATE TABLE v2_last_contract_revision ( contract_element_id INTEGER UNIQUE REFERENCES v2_file_contract_elements(id) ON DELETE CASCADE NOT NULL ); -CREATE TABLE v2_transaction_events ( - event_id INTEGER PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE NOT NULL, - transaction_id INTEGER REFERENCES v2_transactions(id) ON DELETE CASCADE NOT NULL -); - CREATE TABLE host_info ( public_key BLOB PRIMARY KEY NOT NULL, net_address TEXT NOT NULL, diff --git a/persist/sqlite/v2consensus_test.go b/persist/sqlite/v2consensus_test.go index 24a883e..01f0d3f 100644 --- a/persist/sqlite/v2consensus_test.go +++ b/persist/sqlite/v2consensus_test.go @@ -10,6 +10,7 @@ import ( rhp2 "go.sia.tech/core/rhp/v2" "go.sia.tech/core/types" "go.sia.tech/coreutils/chain" + "go.sia.tech/coreutils/wallet" "go.sia.tech/explored/explorer" "go.sia.tech/explored/internal/testutil" ) @@ -295,11 +296,10 @@ func TestV2Attestations(t *testing.T) { if err != nil { t.Fatal(err) } - if v, ok := events[0].Data.(*explorer.EventV2Transaction); !ok { - t.Fatal("expected EventV2Transaction") - } else { - testutil.CheckV2Transaction(t, txn1, explorer.V2Transaction(*v)) - } + testutil.Equal(t, "events", 2, len(events)) + + testutil.Equal(t, "event 0", txn1, types.V2Transaction(events[0].Data.(wallet.EventV2Transaction))) + testutil.Equal(t, "event 1", genesisBlock.Transactions[0], events[1].Data.(wallet.EventV1Transaction).Transaction) } { @@ -952,6 +952,9 @@ func TestV2FileContractResolution(t *testing.T) { addr1 := types.StandardUnlockHash(pk1.PublicKey()) addr1Policy := types.SpendPolicy{Type: types.PolicyTypeUnlockConditions(types.StandardUnlockConditions(pk1.PublicKey()))} + pk2 := types.GeneratePrivateKey() + addr2 := types.StandardUnlockHash(pk2.PublicKey()) + renterPrivateKey := types.GeneratePrivateKey() renterPublicKey := renterPrivateKey.PublicKey() @@ -965,7 +968,7 @@ func TestV2FileContractResolution(t *testing.T) { }) giftSC := genesisBlock.Transactions[0].SiacoinOutputs[0].Value - v1FC := testutil.PrepareContractFormation(renterPublicKey, hostPublicKey, types.Siacoins(1), types.Siacoins(1), 100, 105, types.VoidAddress) + v1FC := testutil.PrepareContractFormation(renterPublicKey, hostPublicKey, types.Siacoins(1), types.Siacoins(1), 100, 105, addr2) v1FC.Filesize = 65 data := make([]byte, 2*rhp2.LeafSize) @@ -1164,6 +1167,32 @@ func TestV2FileContractResolution(t *testing.T) { testutil.Equal(t, "resolution transaction ID", txn4.ID(), *dbTxns[0].FileContractResolutions[0].Parent.ResolutionTransactionID) } + { + events, err := db.AddressEvents(addr2, 0, math.MaxInt64) + if err != nil { + t.Fatal(err) + } + testutil.Equal(t, "events", 3, len(events)) + + ev0 := events[0].Data.(wallet.EventV2ContractResolution) + testutil.Equal(t, "event 0 parent ID", v2FC3ID, ev0.Resolution.Parent.ID) + testutil.Equal(t, "event 0 output ID", v2FC3ID.V2RenterOutputID(), ev0.SiacoinElement.ID) + testutil.Equal(t, "event 0 missed", true, ev0.Missed) + testutil.Equal(t, "event 0 resolution", txn4.FileContractResolutions[0], ev0.Resolution) + + ev1 := events[1].Data.(wallet.EventV2ContractResolution) + testutil.Equal(t, "event 0 parent ID", v2FC2ID, ev1.Resolution.Parent.ID) + testutil.Equal(t, "event 0 output ID", v2FC2ID.V2RenterOutputID(), ev1.SiacoinElement.ID) + testutil.Equal(t, "event 0 missed", false, ev1.Missed) + testutil.Equal(t, "event 0 resolution", txn3.FileContractResolutions[0], ev1.Resolution) + + ev2 := events[2].Data.(wallet.EventV2ContractResolution) + testutil.Equal(t, "event 0 parent ID", v2FC1ID, ev2.Resolution.Parent.ID) + testutil.Equal(t, "event 0 output ID", v2FC1ID.V2RenterOutputID(), ev2.SiacoinElement.ID) + testutil.Equal(t, "event 0 missed", false, ev2.Missed) + testutil.Equal(t, "event 0 resolution", txn2.FileContractResolutions[0], ev2.Resolution) + } + // revert the block { state := prevState diff --git a/persist/sqlite/v2transactions.go b/persist/sqlite/v2transactions.go index d799fa5..4ef759f 100644 --- a/persist/sqlite/v2transactions.go +++ b/persist/sqlite/v2transactions.go @@ -427,6 +427,75 @@ ORDER BY ts.transaction_order ASC`) return nil } +func scanResolution(fcStmt *stmt, s scanner) (explorer.V2FileContractResolution, error) { + // all + var parentContractID, resolutionType int64 + // renewal + var renewalNewContractID sql.NullInt64 + var finalRenterOutput, finalHostOutput types.SiacoinOutput + var renewalRenterRollover, renewalHostRollover types.Currency + var renewalRenterSignature, renewalHostSignature types.Signature + // storage proof + var storageProofProofIndex types.ChainIndexElement + var storageProofProof []types.Hash256 + var storageProofLeaf []byte + + // Scan all fields, some of which may be NULL + if err := s.Scan( + &parentContractID, &resolutionType, + &renewalNewContractID, + decodeNull(&finalRenterOutput.Address), decodeNull(&finalRenterOutput.Value), + decodeNull(&finalHostOutput.Address), decodeNull(&finalHostOutput.Value), + decodeNull(&renewalRenterRollover), decodeNull(&renewalHostRollover), + decodeNull(&renewalRenterSignature), decodeNull(&renewalHostSignature), + decodeNull(&storageProofProofIndex), &storageProofLeaf, decodeNull(&storageProofProof)); err != nil { + return explorer.V2FileContractResolution{}, fmt.Errorf("failed to scan resolution metadata: %w", err) + } + + // Retrieve parent contract element + parent, err := scanV2FileContract(fcStmt.QueryRow(parentContractID)) + if err != nil { + return explorer.V2FileContractResolution{}, fmt.Errorf("failed to scan file contract: %w", err) + } + + fcr := explorer.V2FileContractResolution{ + Parent: parent, + } + switch resolutionType { + case 0: // V2FileContractRenewal + renewal := &explorer.V2FileContractRenewal{ + FinalRenterOutput: finalRenterOutput, + FinalHostOutput: finalHostOutput, + RenterRollover: renewalRenterRollover, + HostRollover: renewalHostRollover, + RenterSignature: renewalRenterSignature, + HostSignature: renewalHostSignature, + } + if renewalNewContractID.Valid { + renewal.NewContract, err = scanV2FileContract(fcStmt.QueryRow(renewalNewContractID.Int64)) + if err != nil { + return explorer.V2FileContractResolution{}, fmt.Errorf("failed to scan new contract: %w", err) + } + } + + fcr.Type = "renewal" + fcr.Resolution = renewal + case 1: // V2StorageProof + proof := &types.V2StorageProof{ + ProofIndex: storageProofProofIndex, + Proof: storageProofProof, + Leaf: [64]byte(storageProofLeaf), + } + + fcr.Type = "storageProof" + fcr.Resolution = proof + case 2: // V2FileContractExpiration + fcr.Type = "expiration" + fcr.Resolution = new(types.V2FileContractExpiration) + } + return fcr, nil +} + // fillV2TransactionFileContractResolutions fills in the file contract // resolutions for each transaction. func fillV2TransactionFileContractResolutions(tx *txn, dbIDs []int64, txns []explorer.V2Transaction) error { @@ -467,74 +536,13 @@ WHERE fc.id = ?`) defer rows.Close() for rows.Next() { - // all - var parentContractID, resolutionType int64 - // renewal - var renewalNewContractID sql.NullInt64 - var finalRenterOutput, finalHostOutput types.SiacoinOutput - var renewalRenterRollover, renewalHostRollover types.Currency - var renewalRenterSignature, renewalHostSignature types.Signature - // storage proof - var storageProofProofIndex types.ChainIndexElement - var storageProofProof []types.Hash256 - var storageProofLeaf []byte - - // Scan all fields, some of which may be NULL - if err := rows.Scan( - &parentContractID, &resolutionType, - &renewalNewContractID, - decodeNull(&finalRenterOutput.Address), decodeNull(&finalRenterOutput.Value), - decodeNull(&finalHostOutput.Address), decodeNull(&finalHostOutput.Value), - decodeNull(&renewalRenterRollover), decodeNull(&renewalHostRollover), - decodeNull(&renewalRenterSignature), decodeNull(&renewalHostSignature), - decodeNull(&storageProofProofIndex), &storageProofLeaf, decodeNull(&storageProofProof)); err != nil { - return fmt.Errorf("failed to scan resolution metadata: %w", err) - } - - // Retrieve parent contract element - parent, err := scanV2FileContract(fcStmt.QueryRow(parentContractID)) + resolution, err := scanResolution(fcStmt, rows) if err != nil { - return fmt.Errorf("failed to scan file contract: %w", err) - } - - fcr := explorer.V2FileContractResolution{ - Parent: parent, - } - switch resolutionType { - case 0: // V2FileContractRenewal - renewal := &explorer.V2FileContractRenewal{ - FinalRenterOutput: finalRenterOutput, - FinalHostOutput: finalHostOutput, - RenterRollover: renewalRenterRollover, - HostRollover: renewalHostRollover, - RenterSignature: renewalRenterSignature, - HostSignature: renewalHostSignature, - } - if renewalNewContractID.Valid { - renewal.NewContract, err = scanV2FileContract(fcStmt.QueryRow(renewalNewContractID.Int64)) - if err != nil { - return fmt.Errorf("failed to scan new contract: %w", err) - } - } - - fcr.Type = "renewal" - fcr.Resolution = renewal - case 1: // V2StorageProof - proof := &types.V2StorageProof{ - ProofIndex: storageProofProofIndex, - Proof: storageProofProof, - Leaf: [64]byte(storageProofLeaf), - } - - fcr.Type = "storageProof" - fcr.Resolution = proof - case 2: // V2FileContractExpiration - fcr.Type = "expiration" - fcr.Resolution = new(types.V2FileContractExpiration) + return fmt.Errorf("failed to scan resolution: %w", err) } // Append the resolution to the transaction. - txns[i].FileContractResolutions = append(txns[i].FileContractResolutions, fcr) + txns[i].FileContractResolutions = append(txns[i].FileContractResolutions, resolution) } return nil }()