diff --git a/api/client.go b/api/client.go index 6ba02c3d..45b4f4e7 100644 --- a/api/client.go +++ b/api/client.go @@ -184,6 +184,13 @@ func (c *Client) ContractsKey(key types.PublicKey) (resp []explorer.FileContract return } +// ContractRevisions returns all the revisions of the contract with the +// specified ID. +func (c *Client) ContractRevisions(id types.FileContractID) (resp []types.FileContractElement, err error) { + err = c.c.GET(fmt.Sprintf("/contracts/%s/revisions", id), &resp) + return +} + // Host returns information about the host with a given ed25519 key. func (c *Client) Host(key types.PublicKey) (resp explorer.Host, err error) { err = c.c.GET(fmt.Sprintf("/pubkey/%s/host", key), &resp) diff --git a/api/server.go b/api/server.go index 1092513c..1978143a 100644 --- a/api/server.go +++ b/api/server.go @@ -62,6 +62,7 @@ type ( AddressEvents(address types.Address, offset, limit uint64) (events []explorer.Event, err error) Contracts(ids []types.FileContractID) (result []explorer.FileContract, err error) ContractsKey(key types.PublicKey) (result []explorer.FileContract, err error) + ContractRevisions(id types.FileContractID) (result []types.FileContractElement, err error) Search(id types.Hash256) (explorer.SearchType, error) Hosts(pks []types.PublicKey) ([]explorer.Host, error) @@ -73,7 +74,26 @@ const ( ) var ( - errTooManyIDs = fmt.Errorf("too many IDs provided (provide less than %d)", maxIDs) + // ErrTransactionNotFound is returned by /transactions/:id when we are + // unable to find the transaction with that `id`. + ErrTransactionNotFound = errors.New("no transaction found") + // ErrSiacoinOutputNotFound is returned by /outputs/siacoin/:id when we + // are unable to find the siacoin output with that `id`. + ErrSiacoinOutputNotFound = errors.New("no siacoin output found") + // ErrSiafundOutputNotFound is returned by /outputs/siafund/:id when we + // are unable to find the siafund output with that `id`. + ErrSiafundOutputNotFound = errors.New("no siafund output found") + // ErrHostNotFound is returned by /pubkey/:key/host when we are unable to + // find the host with the pubkey `key`. + ErrHostNotFound = errors.New("no host found") + + // ErrNoSearchResults is returned by /search/:id when we do not find any + // elements with that ID. + ErrNoSearchResults = errors.New("no search results found") + + // ErrTooManyIDs is returned by the batch transaction and contract + // endpoints when more than maxIDs IDs are specified. + ErrTooManyIDs = fmt.Errorf("too many IDs provided (provide less than %d)", maxIDs) ) type server struct { @@ -247,8 +267,6 @@ func (s *server) blocksIDHandler(jc jape.Context) { } func (s *server) transactionsIDHandler(jc jape.Context) { - errNotFound := errors.New("no transaction found") - var id types.TransactionID if jc.DecodeParam("id", &id) != nil { return @@ -257,7 +275,7 @@ func (s *server) transactionsIDHandler(jc jape.Context) { if jc.Check("failed to get transaction", err) != nil { return } else if len(txns) == 0 { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(ErrTransactionNotFound, http.StatusNotFound) return } jc.Encode(txns[0]) @@ -291,7 +309,7 @@ func (s *server) transactionsBatchHandler(jc jape.Context) { if jc.Decode(&ids) != nil { return } else if len(ids) > maxIDs { - jc.Error(errTooManyIDs, http.StatusBadRequest) + jc.Error(ErrTooManyIDs, http.StatusBadRequest) return } @@ -379,8 +397,6 @@ func (s *server) addressessAddressEventsHandler(jc jape.Context) { } func (s *server) outputsSiacoinHandler(jc jape.Context) { - errNotFound := errors.New("no siacoin output found") - var id types.SiacoinOutputID if jc.DecodeParam("id", &id) != nil { return @@ -390,7 +406,7 @@ func (s *server) outputsSiacoinHandler(jc jape.Context) { if jc.Check("failed to get siacoin elements", err) != nil { return } else if len(outputs) == 0 { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(ErrSiacoinOutputNotFound, http.StatusNotFound) return } @@ -398,8 +414,6 @@ func (s *server) outputsSiacoinHandler(jc jape.Context) { } func (s *server) outputsSiafundHandler(jc jape.Context) { - errNotFound := errors.New("no siafund output found") - var id types.SiafundOutputID if jc.DecodeParam("id", &id) != nil { return @@ -409,15 +423,13 @@ func (s *server) outputsSiafundHandler(jc jape.Context) { if jc.Check("failed to get siafund elements", err) != nil { return } else if len(outputs) == 0 { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(ErrSiafundOutputNotFound, http.StatusNotFound) return } jc.Encode(outputs[0]) } func (s *server) contractsIDHandler(jc jape.Context) { - errNotFound := errors.New("no contract found") - var id types.FileContractID if jc.DecodeParam("id", &id) != nil { return @@ -426,18 +438,34 @@ func (s *server) contractsIDHandler(jc jape.Context) { if jc.Check("failed to get contract", err) != nil { return } else if len(fcs) == 0 { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(explorer.ErrContractNotFound, http.StatusNotFound) return } jc.Encode(fcs[0]) } +func (s *server) contractsIDRevisionsHandler(jc jape.Context) { + var id types.FileContractID + if jc.DecodeParam("id", &id) != nil { + return + } + + fcs, err := s.e.ContractRevisions(id) + if errors.Is(err, explorer.ErrContractNotFound) { + jc.Error(fmt.Errorf("%w: %v", err, id), http.StatusNotFound) + return + } else if jc.Check("failed to fetch contract revisions", err) != nil { + return + } + jc.Encode(fcs) +} + func (s *server) contractsBatchHandler(jc jape.Context) { var ids []types.FileContractID if jc.Decode(&ids) != nil { return } else if len(ids) > maxIDs { - jc.Error(errTooManyIDs, http.StatusBadRequest) + jc.Error(ErrTooManyIDs, http.StatusBadRequest) return } @@ -449,8 +477,6 @@ func (s *server) contractsBatchHandler(jc jape.Context) { } func (s *server) pubkeyContractsHandler(jc jape.Context) { - errNotFound := errors.New("no contract found") - var key types.PublicKey if jc.DecodeParam("key", &key) != nil { return @@ -459,15 +485,13 @@ func (s *server) pubkeyContractsHandler(jc jape.Context) { if jc.Check("failed to get contracts", err) != nil { return } else if len(fcs) == 0 { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(explorer.ErrContractNotFound, http.StatusNotFound) return } jc.Encode(fcs) } func (s *server) pubkeyHostHandler(jc jape.Context) { - errNotFound := errors.New("host not found") - var key types.PublicKey if jc.DecodeParam("key", &key) != nil { return @@ -476,14 +500,13 @@ func (s *server) pubkeyHostHandler(jc jape.Context) { if jc.Check("failed to get host", err) != nil { return } else if len(hosts) == 0 { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(ErrHostNotFound, http.StatusNotFound) return } jc.Encode(hosts[0]) } func (s *server) searchIDHandler(jc jape.Context) { - errNotFound := errors.New("no contract found") const maxLen = len(types.Hash256{}) // get everything after separator if there is one @@ -498,7 +521,7 @@ func (s *server) searchIDHandler(jc jape.Context) { if jc.Check("failed to search ID", err) != nil { return } else if result == explorer.SearchTypeInvalid { - jc.Error(errNotFound, http.StatusNotFound) + jc.Error(ErrNoSearchResults, http.StatusNotFound) return } jc.Encode(result) @@ -543,8 +566,9 @@ func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { "GET /outputs/siacoin/:id": srv.outputsSiacoinHandler, "GET /outputs/siafund/:id": srv.outputsSiafundHandler, - "GET /contracts/:id": srv.contractsIDHandler, - "POST /contracts": srv.contractsBatchHandler, + "GET /contracts/:id": srv.contractsIDHandler, + "GET /contracts/:id/revisions": srv.contractsIDRevisionsHandler, + "POST /contracts": srv.contractsBatchHandler, "GET /pubkey/:key/contracts": srv.pubkeyContractsHandler, "GET /pubkey/:key/host": srv.pubkeyHostHandler, diff --git a/explorer/explorer.go b/explorer/explorer.go index 3e778476..48c8e989 100644 --- a/explorer/explorer.go +++ b/explorer/explorer.go @@ -20,6 +20,10 @@ var ( // database and thus there is no tip. It does not mean there was an // error in the underlying database. ErrNoTip = errors.New("no tip found") + + // ErrContractNotFound is returned when ContractRevisions is unable to find + // the specified contract ID. + ErrContractNotFound = errors.New("contract not found") ) // A ChainManager manages the consensus state @@ -52,6 +56,7 @@ type Store interface { Balance(address types.Address) (sc types.Currency, immatureSC types.Currency, sf uint64, err error) Contracts(ids []types.FileContractID) (result []FileContract, err error) ContractsKey(key types.PublicKey) (result []FileContract, err error) + ContractRevisions(id types.FileContractID) (result []types.FileContractElement, err error) SiacoinElements(ids []types.SiacoinOutputID) (result []SiacoinOutput, err error) SiafundElements(ids []types.SiafundOutputID) (result []SiafundOutput, err error) @@ -236,6 +241,12 @@ func (e *Explorer) ContractsKey(key types.PublicKey) (result []FileContract, err return e.s.ContractsKey(key) } +// ContractRevisions returns all the revisions of the contract with the +// specified ID. +func (e *Explorer) ContractRevisions(id types.FileContractID) (result []types.FileContractElement, err error) { + return e.s.ContractRevisions(id) +} + // SiacoinElements returns the siacoin elements with the specified IDs. func (e *Explorer) SiacoinElements(ids []types.SiacoinOutputID) (result []SiacoinOutput, err error) { return e.s.SiacoinElements(ids) diff --git a/persist/sqlite/consensus.go b/persist/sqlite/consensus.go index 04640cc0..961bd545 100644 --- a/persist/sqlite/consensus.go +++ b/persist/sqlite/consensus.go @@ -165,18 +165,6 @@ func addFileContracts(tx *txn, id int64, txn types.Transaction, fcDBIds map[expl } defer stmt.Close() - validOutputsStmt, err := tx.Prepare(`INSERT INTO file_contract_valid_proof_outputs(contract_id, contract_order, address, value) VALUES (?, ?, ?, ?)`) - if err != nil { - return fmt.Errorf("addFileContracts: failed to prepare valid proof outputs statement: %w", err) - } - defer validOutputsStmt.Close() - - missedOutputsStmt, err := tx.Prepare(`INSERT INTO file_contract_missed_proof_outputs(contract_id, contract_order, address, value) VALUES (?, ?, ?, ?)`) - if err != nil { - return fmt.Errorf("addFileContracts: failed to prepare missed proof outputs statement: %w", err) - } - defer missedOutputsStmt.Close() - for i := range txn.FileContracts { dbID, ok := fcDBIds[explorer.DBFileContract{ID: txn.FileContractID(i), RevisionNumber: 0}] if !ok { @@ -186,18 +174,6 @@ func addFileContracts(tx *txn, id int64, txn types.Transaction, fcDBIds map[expl if _, err := stmt.Exec(id, i, dbID); err != nil { return fmt.Errorf("addFileContracts: failed to execute transaction_file_contracts statement: %w", err) } - - for j, sco := range txn.FileContracts[i].ValidProofOutputs { - if _, err := validOutputsStmt.Exec(dbID, j, encode(sco.Address), encode(sco.Value)); err != nil { - return fmt.Errorf("addFileContracts: failed to execute valid proof outputs statement: %w", err) - } - } - - for j, sco := range txn.FileContracts[i].MissedProofOutputs { - if _, err := missedOutputsStmt.Exec(dbID, j, encode(sco.Address), encode(sco.Value)); err != nil { - return fmt.Errorf("addFileContracts: failed to execute missed proof outputs statement: %w", err) - } - } } return nil } @@ -209,18 +185,6 @@ func addFileContractRevisions(tx *txn, id int64, txn types.Transaction, dbIDs ma } defer stmt.Close() - validOutputsStmt, err := tx.Prepare(`INSERT INTO file_contract_valid_proof_outputs(contract_id, contract_order, address, value) VALUES (?, ?, ?, ?)`) - if err != nil { - return fmt.Errorf("addFileContracts: failed to prepare valid proof outputs statement: %w", err) - } - defer validOutputsStmt.Close() - - missedOutputsStmt, err := tx.Prepare(`INSERT INTO file_contract_missed_proof_outputs(contract_id, contract_order, address, value) VALUES (?, ?, ?, ?)`) - if err != nil { - return fmt.Errorf("addFileContracts: failed to prepare missed proof outputs statement: %w", err) - } - defer missedOutputsStmt.Close() - for i := range txn.FileContractRevisions { fcr := &txn.FileContractRevisions[i] dbID, ok := dbIDs[explorer.DBFileContract{ID: fcr.ParentID, RevisionNumber: fcr.FileContract.RevisionNumber}] @@ -231,18 +195,6 @@ func addFileContractRevisions(tx *txn, id int64, txn types.Transaction, dbIDs ma if _, err := stmt.Exec(id, i, dbID, encode(fcr.ParentID), encode(fcr.UnlockConditions)); err != nil { return fmt.Errorf("addFileContractRevisions: failed to execute statement: %w", err) } - - for j, sco := range txn.FileContractRevisions[i].ValidProofOutputs { - if _, err := validOutputsStmt.Exec(dbID, j, encode(sco.Address), encode(sco.Value)); err != nil { - return fmt.Errorf("addFileContractRevisions: failed to execute valid proof outputs statement: %w", err) - } - } - - for j, sco := range txn.FileContractRevisions[i].MissedProofOutputs { - if _, err := missedOutputsStmt.Exec(dbID, j, encode(sco.Address), encode(sco.Value)); err != nil { - return fmt.Errorf("addFileContractRevisions: failed to execute missed proof outputs statement: %w", err) - } - } } return nil @@ -761,8 +713,8 @@ func deleteBlock(tx *txn, bid types.BlockID) error { } func updateFileContractElements(tx *txn, revert bool, b types.Block, fces []explorer.FileContractUpdate) (map[explorer.DBFileContract]int64, error) { - stmt, err := tx.Prepare(`INSERT INTO file_contract_elements(contract_id, leaf_index, resolved, valid, filesize, file_merkle_root, window_start, window_end, payout, unlock_hash, revision_number) - VALUES (?, ?, FALSE, FALSE, ?, ?, ?, ?, ?, ?, ?) + stmt, err := tx.Prepare(`INSERT INTO file_contract_elements(contract_id, block_id, leaf_index, resolved, valid, filesize, file_merkle_root, window_start, window_end, payout, unlock_hash, revision_number) + VALUES (?, ?, ?, FALSE, FALSE, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (contract_id, revision_number) DO UPDATE SET resolved = ?, valid = ?, leaf_index = ? RETURNING id;`) @@ -771,8 +723,8 @@ func updateFileContractElements(tx *txn, revert bool, b types.Block, fces []expl } defer stmt.Close() - revisionStmt, err := tx.Prepare(`INSERT INTO last_contract_revision(contract_id, block_id, contract_element_id, ed25519_renter_key, ed25519_host_key) - VALUES (?, ?, ?, ?, ?) + revisionStmt, err := tx.Prepare(`INSERT INTO last_contract_revision(contract_id, contract_element_id, ed25519_renter_key, ed25519_host_key) + VALUES (?, ?, ?, ?) ON CONFLICT (contract_id) DO UPDATE SET contract_element_id = ?, ed25519_renter_key = COALESCE(?, ed25519_renter_key), ed25519_host_key = COALESCE(?, ed25519_host_key)`) if err != nil { @@ -780,6 +732,18 @@ func updateFileContractElements(tx *txn, revert bool, b types.Block, fces []expl } defer revisionStmt.Close() + validOutputsStmt, err := tx.Prepare(`INSERT INTO file_contract_valid_proof_outputs(contract_id, contract_order, address, value) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING`) + if err != nil { + return nil, fmt.Errorf("addFileContracts: failed to prepare valid proof outputs statement: %w", err) + } + defer validOutputsStmt.Close() + + missedOutputsStmt, err := tx.Prepare(`INSERT INTO file_contract_missed_proof_outputs(contract_id, contract_order, address, value) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING`) + if err != nil { + return nil, fmt.Errorf("addFileContracts: failed to prepare missed proof outputs statement: %w", err) + } + defer missedOutputsStmt.Close() + fcKeys := make(map[explorer.DBFileContract][2]types.PublicKey) // populate fcKeys using revision UnlockConditions fields for _, txn := range b.Transactions { @@ -815,11 +779,22 @@ func updateFileContractElements(tx *txn, revert bool, b types.Block, fces []expl addFC := func(fcID types.FileContractID, leafIndex uint64, fc types.FileContract, resolved, valid, lastRevision bool) error { var dbID int64 dbFC := explorer.DBFileContract{ID: fcID, RevisionNumber: fc.RevisionNumber} - err := stmt.QueryRow(encode(fcID), encode(leafIndex), encode(fc.Filesize), encode(fc.FileMerkleRoot), encode(fc.WindowStart), encode(fc.WindowEnd), encode(fc.Payout), encode(fc.UnlockHash), encode(fc.RevisionNumber), resolved, valid, encode(leafIndex)).Scan(&dbID) + err := stmt.QueryRow(encode(fcID), encode(b.ID()), encode(leafIndex), encode(fc.Filesize), encode(fc.FileMerkleRoot), encode(fc.WindowStart), encode(fc.WindowEnd), encode(fc.Payout), encode(fc.UnlockHash), encode(fc.RevisionNumber), resolved, valid, encode(leafIndex)).Scan(&dbID) if err != nil { return fmt.Errorf("failed to execute file_contract_elements statement: %w", err) } + for i, sco := range fc.ValidProofOutputs { + if _, err := validOutputsStmt.Exec(dbID, i, encode(sco.Address), encode(sco.Value)); err != nil { + return fmt.Errorf("updateFileContractElements: failed to execute valid proof outputs statement: %w", err) + } + } + for i, sco := range fc.MissedProofOutputs { + if _, err := missedOutputsStmt.Exec(dbID, i, encode(sco.Address), encode(sco.Value)); err != nil { + return fmt.Errorf("updateFileContractElements: failed to execute missed proof outputs statement: %w", err) + } + } + // only update if it's the most recent revision which will come from // running ForEachFileContractElement on the update if lastRevision { @@ -829,7 +804,7 @@ func updateFileContractElements(tx *txn, revert bool, b types.Block, fces []expl hostKey = encode(keys[1]).([]byte) } - if _, err := revisionStmt.Exec(encode(fcID), encode(b.ID()), dbID, renterKey, hostKey, dbID, renterKey, hostKey); err != nil { + if _, err := revisionStmt.Exec(encode(fcID), dbID, renterKey, hostKey, dbID, renterKey, hostKey); err != nil { return fmt.Errorf("failed to update last revision number: %w", err) } } diff --git a/persist/sqlite/consensus_test.go b/persist/sqlite/consensus_test.go index 9eb87d8e..0f04dd6c 100644 --- a/persist/sqlite/consensus_test.go +++ b/persist/sqlite/consensus_test.go @@ -139,6 +139,82 @@ func checkMetrics(t *testing.T, db explorer.Store, cm *chain.Manager, expected e // don't check circulating supply here because it requires a lot of accounting } +func checkFCRevisions(t *testing.T, revisionNumbers []uint64, fcs []types.FileContractElement) { + t.Helper() + + check(t, "number of revisions", len(revisionNumbers), len(fcs)) + for i := range revisionNumbers { + check(t, "revision number", revisionNumbers[i], fcs[i].FileContract.RevisionNumber) + } +} + +func checkTransaction(t *testing.T, expectTxn types.Transaction, gotTxn explorer.Transaction) { + t.Helper() + + check(t, "siacoin inputs", len(expectTxn.SiacoinInputs), len(gotTxn.SiacoinInputs)) + check(t, "siacoin outputs", len(expectTxn.SiacoinOutputs), len(gotTxn.SiacoinOutputs)) + check(t, "siafund inputs", len(expectTxn.SiafundInputs), len(gotTxn.SiafundInputs)) + check(t, "siafund outputs", len(expectTxn.SiafundOutputs), len(gotTxn.SiafundOutputs)) + check(t, "miner fees", len(expectTxn.MinerFees), len(gotTxn.MinerFees)) + check(t, "signatures", len(expectTxn.Signatures), len(gotTxn.Signatures)) + + for i := range expectTxn.SiacoinInputs { + expectSci := expectTxn.SiacoinInputs[i] + gotSci := gotTxn.SiacoinInputs[i] + + if gotSci.Value == types.ZeroCurrency { + t.Fatal("invalid value") + } + check(t, "parent ID", expectSci.ParentID, gotSci.ParentID) + check(t, "unlock conditions", expectSci.UnlockConditions, gotSci.UnlockConditions) + check(t, "address", expectSci.UnlockConditions.UnlockHash(), gotSci.Address) + } + for i := range expectTxn.SiacoinOutputs { + expectSco := expectTxn.SiacoinOutputs[i] + gotSco := gotTxn.SiacoinOutputs[i].SiacoinOutput + + check(t, "address", expectSco.Address, gotSco.Address) + check(t, "value", expectSco.Value, gotSco.Value) + check(t, "source", explorer.SourceTransaction, gotTxn.SiacoinOutputs[i].Source) + } + for i := range expectTxn.SiafundInputs { + expectSfi := expectTxn.SiafundInputs[i] + gotSfi := gotTxn.SiafundInputs[i] + + if gotSfi.Value == 0 { + t.Fatal("invalid value") + } + check(t, "parent ID", expectSfi.ParentID, gotSfi.ParentID) + check(t, "claim address", expectSfi.ClaimAddress, gotSfi.ClaimAddress) + check(t, "unlock conditions", expectSfi.UnlockConditions, gotSfi.UnlockConditions) + check(t, "address", expectSfi.UnlockConditions.UnlockHash(), gotSfi.Address) + } + for i := range expectTxn.SiafundOutputs { + expectSfo := expectTxn.SiafundOutputs[i] + gotSfo := gotTxn.SiafundOutputs[i].SiafundOutput + + check(t, "address", expectSfo.Address, gotSfo.Address) + check(t, "value", expectSfo.Value, gotSfo.Value) + } + for i := range expectTxn.MinerFees { + check(t, "miner fee", expectTxn.MinerFees[i], gotTxn.MinerFees[i]) + } + for i := range expectTxn.Signatures { + expectSig := expectTxn.Signatures[i] + gotSig := gotTxn.Signatures[i] + + check(t, "parent ID", expectSig.ParentID, gotSig.ParentID) + check(t, "public key index", expectSig.PublicKeyIndex, gotSig.PublicKeyIndex) + check(t, "timelock", expectSig.Timelock, gotSig.Timelock) + check(t, "signature", expectSig.Signature, gotSig.Signature) + + // reflect.DeepEqual treats empty slices as different from nil + // slices so these will differ because the decoder is doing + // cf.X = make([]uint64, d.ReadPrefix()) and the prefix is 0 + // check(t, "covered fields", expectSig.CoveredFields, gotSig.CoveredFields) + } +} + func syncDB(t *testing.T, db *sqlite.Store, cm *chain.Manager) { index, err := db.Tip() if err != nil && !errors.Is(err, explorer.ErrNoTip) { @@ -425,71 +501,6 @@ func TestSendTransactions(t *testing.T) { } } - checkTransaction := func(expectTxn types.Transaction, gotTxn explorer.Transaction) { - check(t, "siacoin inputs", len(expectTxn.SiacoinInputs), len(gotTxn.SiacoinInputs)) - check(t, "siacoin outputs", len(expectTxn.SiacoinOutputs), len(gotTxn.SiacoinOutputs)) - check(t, "siafund inputs", len(expectTxn.SiafundInputs), len(gotTxn.SiafundInputs)) - check(t, "siafund outputs", len(expectTxn.SiafundOutputs), len(gotTxn.SiafundOutputs)) - check(t, "miner fees", len(expectTxn.MinerFees), len(gotTxn.MinerFees)) - check(t, "signatures", len(expectTxn.Signatures), len(gotTxn.Signatures)) - - for i := range expectTxn.SiacoinInputs { - expectSci := expectTxn.SiacoinInputs[i] - gotSci := gotTxn.SiacoinInputs[i] - - if gotSci.Value == types.ZeroCurrency { - t.Fatal("invalid value") - } - check(t, "parent ID", expectSci.ParentID, gotSci.ParentID) - check(t, "unlock conditions", expectSci.UnlockConditions, gotSci.UnlockConditions) - check(t, "address", expectSci.UnlockConditions.UnlockHash(), gotSci.Address) - } - for i := range expectTxn.SiacoinOutputs { - expectSco := expectTxn.SiacoinOutputs[i] - gotSco := gotTxn.SiacoinOutputs[i].SiacoinOutput - - check(t, "address", expectSco.Address, gotSco.Address) - check(t, "value", expectSco.Value, gotSco.Value) - check(t, "source", explorer.SourceTransaction, gotTxn.SiacoinOutputs[i].Source) - } - for i := range expectTxn.SiafundInputs { - expectSfi := expectTxn.SiafundInputs[i] - gotSfi := gotTxn.SiafundInputs[i] - - if gotSfi.Value == 0 { - t.Fatal("invalid value") - } - check(t, "parent ID", expectSfi.ParentID, gotSfi.ParentID) - check(t, "claim address", expectSfi.ClaimAddress, gotSfi.ClaimAddress) - check(t, "unlock conditions", expectSfi.UnlockConditions, gotSfi.UnlockConditions) - check(t, "address", expectSfi.UnlockConditions.UnlockHash(), gotSfi.Address) - } - for i := range expectTxn.SiafundOutputs { - expectSfo := expectTxn.SiafundOutputs[i] - gotSfo := gotTxn.SiafundOutputs[i].SiafundOutput - - check(t, "address", expectSfo.Address, gotSfo.Address) - check(t, "value", expectSfo.Value, gotSfo.Value) - } - for i := range expectTxn.MinerFees { - check(t, "miner fee", expectTxn.MinerFees[i], gotTxn.MinerFees[i]) - } - for i := range expectTxn.Signatures { - expectSig := expectTxn.Signatures[i] - gotSig := gotTxn.Signatures[i] - - check(t, "parent ID", expectSig.ParentID, gotSig.ParentID) - check(t, "public key index", expectSig.PublicKeyIndex, gotSig.PublicKeyIndex) - check(t, "timelock", expectSig.Timelock, gotSig.Timelock) - check(t, "signature", expectSig.Signature, gotSig.Signature) - - // reflect.DeepEqual treats empty slices as different from nil - // slices so these will differ because the decoder is doing - // cf.X = make([]uint64, d.ReadPrefix()) and the prefix is 0 - // check(t, "covered fields", expectSig.CoveredFields, gotSig.CoveredFields) - } - } - expectedPayout := cm.TipState().BlockReward() maturityHeight := cm.TipState().MaturityHeight() @@ -592,7 +603,7 @@ func TestSendTransactions(t *testing.T) { // Ensure the transactions in the block and retrieved separately match // with the actual transactions for i := range b.Transactions { - checkTransaction(b.Transactions[i], block.Transactions[i]) + checkTransaction(t, b.Transactions[i], block.Transactions[i]) checkChainIndices(t, b.Transactions[i].ID(), []types.ChainIndex{cm.Tip()}) txns, err := db.Transactions([]types.TransactionID{b.Transactions[i].ID()}) @@ -600,7 +611,7 @@ func TestSendTransactions(t *testing.T) { t.Fatal(err) } check(t, "transactions", 1, len(txns)) - checkTransaction(b.Transactions[i], txns[0]) + checkTransaction(t, b.Transactions[i], txns[0]) } type expectedUTXOs struct { @@ -859,6 +870,14 @@ func TestFileContract(t *testing.T) { check(t, "confirmation transaction ID", txn.ID(), *dbFCs[0].ConfirmationTransactionID) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0}, dbFCs) + } + { txns, err := db.Transactions([]types.TransactionID{txn.ID()}) if err != nil { @@ -931,6 +950,14 @@ func TestFileContract(t *testing.T) { checkFC(false, false, fc, dbFCs[0]) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0, 1}, dbFCs) + } + { txns, err := db.Transactions([]types.TransactionID{reviseTxn.ID()}) if err != nil { @@ -1181,6 +1208,14 @@ func TestEphemeralFileContract(t *testing.T) { checkFC(true, false, false, revisedFC1, dbFCs[0]) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0, 1}, dbFCs) + } + { txns, err := db.Transactions([]types.TransactionID{txn.ID()}) if err != nil { @@ -1253,6 +1288,14 @@ func TestEphemeralFileContract(t *testing.T) { checkFC(true, false, false, revisedFC3, dbFCs[0]) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0, 1, 2, 3}, dbFCs) + } + { renterContracts, err := db.ContractsKey(renterPublicKey) if err != nil { @@ -1678,52 +1721,6 @@ func TestRevertSendTransactions(t *testing.T) { } } - checkTransaction := func(expectTxn types.Transaction, gotTxn explorer.Transaction) { - check(t, "siacoin inputs", len(expectTxn.SiacoinInputs), len(gotTxn.SiacoinInputs)) - check(t, "siacoin outputs", len(expectTxn.SiacoinOutputs), len(gotTxn.SiacoinOutputs)) - check(t, "siafund inputs", len(expectTxn.SiafundInputs), len(gotTxn.SiafundInputs)) - check(t, "siafund outputs", len(expectTxn.SiafundOutputs), len(gotTxn.SiafundOutputs)) - - for i := range expectTxn.SiacoinInputs { - expectSci := expectTxn.SiacoinInputs[i] - gotSci := gotTxn.SiacoinInputs[i] - - if gotSci.Value == types.ZeroCurrency { - t.Fatal("invalid value") - } - check(t, "parent ID", expectSci.ParentID, gotSci.ParentID) - check(t, "unlock conditions", expectSci.UnlockConditions, gotSci.UnlockConditions) - check(t, "address", expectSci.UnlockConditions.UnlockHash(), gotSci.Address) - } - for i := range expectTxn.SiacoinOutputs { - expectSco := expectTxn.SiacoinOutputs[i] - gotSco := gotTxn.SiacoinOutputs[i].SiacoinOutput - - check(t, "address", expectSco.Address, gotSco.Address) - check(t, "value", expectSco.Value, gotSco.Value) - check(t, "source", explorer.SourceTransaction, gotTxn.SiacoinOutputs[i].Source) - } - for i := range expectTxn.SiafundInputs { - expectSfi := expectTxn.SiafundInputs[i] - gotSfi := gotTxn.SiafundInputs[i] - - if gotSfi.Value == 0 { - t.Fatal("invalid value") - } - check(t, "parent ID", expectSfi.ParentID, gotSfi.ParentID) - check(t, "claim address", expectSfi.ClaimAddress, gotSfi.ClaimAddress) - check(t, "unlock conditions", expectSfi.UnlockConditions, gotSfi.UnlockConditions) - check(t, "address", expectSfi.UnlockConditions.UnlockHash(), gotSfi.Address) - } - for i := range expectTxn.SiafundOutputs { - expectSfo := expectTxn.SiafundOutputs[i] - gotSfo := gotTxn.SiafundOutputs[i].SiafundOutput - - check(t, "address", expectSfo.Address, gotSfo.Address) - check(t, "value", expectSfo.Value, gotSfo.Value) - } - } - expectedPayout := cm.TipState().BlockReward() maturityHeight := cm.TipState().MaturityHeight() @@ -1836,7 +1833,7 @@ func TestRevertSendTransactions(t *testing.T) { // Ensure the transactions in the block and retrieved separately match // with the actual transactions for i := range b.Transactions { - checkTransaction(b.Transactions[i], block.Transactions[i]) + checkTransaction(t, b.Transactions[i], block.Transactions[i]) checkChainIndices(t, b.Transactions[i].ID(), []types.ChainIndex{cm.Tip()}) txns, err := db.Transactions([]types.TransactionID{b.Transactions[i].ID()}) @@ -1844,7 +1841,7 @@ func TestRevertSendTransactions(t *testing.T) { t.Fatal(err) } check(t, "transactions", 1, len(txns)) - checkTransaction(b.Transactions[i], txns[0]) + checkTransaction(t, b.Transactions[i], txns[0]) } type expectedUTXOs struct { @@ -2099,6 +2096,29 @@ func TestHostAnnouncement(t *testing.T) { StorageUtilization: 0, }) + { + b, err := db.Block(cm.Tip().ID) + if err != nil { + t.Fatal(err) + } + check(t, "len(txns)", 3, len(b.Transactions)) + check(t, "txns[0].ID", txn2.ID(), b.Transactions[0].ID) + check(t, "txns[1].ID", txn3.ID(), b.Transactions[1].ID) + check(t, "txns[2].ID", txn4.ID(), b.Transactions[2].ID) + } + + { + dbTxns, err := db.Transactions([]types.TransactionID{txn1.ID(), txn2.ID(), txn3.ID(), txn4.ID()}) + if err != nil { + t.Fatal(err) + } + check(t, "len(txns)", 4, len(dbTxns)) + check(t, "txns[0].ID", txn1.ID(), dbTxns[0].ID) + check(t, "txns[1].ID", txn2.ID(), dbTxns[1].ID) + check(t, "txns[2].ID", txn3.ID(), dbTxns[2].ID) + check(t, "txns[3].ID", txn4.ID(), dbTxns[3].ID) + } + { dbTxns, err := db.Transactions([]types.TransactionID{txn1.ID()}) if err != nil { @@ -2659,6 +2679,14 @@ func TestMultipleReorgFileContract(t *testing.T) { check(t, "confirmation transaction ID", txn.ID(), *dbFCs[0].ConfirmationTransactionID) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0}, dbFCs) + } + { txns, err := db.Transactions([]types.TransactionID{txn.ID()}) if err != nil { @@ -2731,6 +2759,29 @@ func TestMultipleReorgFileContract(t *testing.T) { checkFC(false, false, revFC, fcr.FileContract) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0, 1}, dbFCs) + } + + { + renterContracts, err := db.ContractsKey(renterPublicKey) + if err != nil { + t.Fatal(err) + } + hostContracts, err := db.ContractsKey(hostPublicKey) + if err != nil { + t.Fatal(err) + } + check(t, "renter contracts and host contracts", len(renterContracts), len(hostContracts)) + check(t, "len(contracts)", 1, len(renterContracts)) + checkFC(false, false, revFC, renterContracts[0]) + checkFC(false, false, revFC, hostContracts[0]) + } + extra := cm.Tip().Height - prevState1.Index.Height + 1 for reorg := uint64(0); reorg < 2; reorg++ { // revert the revision @@ -2764,6 +2815,14 @@ func TestMultipleReorgFileContract(t *testing.T) { check(t, "confirmation transaction ID", txn.ID(), *dbFCs[0].ConfirmationTransactionID) } + { + dbFCs, err := db.ContractRevisions(fcID) + if err != nil { + t.Fatal(err) + } + checkFCRevisions(t, []uint64{0}, dbFCs) + } + // storage utilization should be back to contractFilesize instead of // contractFilesize + 10 checkMetrics(t, db, cm, explorer.Metrics{ @@ -2842,6 +2901,26 @@ func TestMultipleReorgFileContract(t *testing.T) { check(t, "fcs", 0, len(dbFCs)) } + { + renterContracts, err := db.ContractsKey(renterPublicKey) + if err != nil { + t.Fatal(err) + } + hostContracts, err := db.ContractsKey(hostPublicKey) + if err != nil { + t.Fatal(err) + } + check(t, "renter contracts and host contracts", len(renterContracts), len(hostContracts)) + check(t, "len(contracts)", 0, len(renterContracts)) + } + + { + _, err := db.ContractRevisions(fcID) + if err != explorer.ErrContractNotFound { + t.Fatal(err) + } + } + // no more contracts or storage utilization checkMetrics(t, db, cm, explorer.Metrics{ TotalHosts: 0, diff --git a/persist/sqlite/contracts.go b/persist/sqlite/contracts.go index a0565981..85edfb5c 100644 --- a/persist/sqlite/contracts.go +++ b/persist/sqlite/contracts.go @@ -15,6 +15,27 @@ func encodedIDs(ids []types.FileContractID) []any { return result } +func scanFileContract(s scanner) (contractID int64, fc explorer.FileContract, err error) { + var confirmationIndex, proofIndex types.ChainIndex + var confirmationTransactionID, proofTransactionID types.TransactionID + err = s.Scan(&contractID, decode(&fc.StateElement.ID), decode(&fc.StateElement.LeafIndex), &fc.Resolved, &fc.Valid, decodeNull(&confirmationIndex), decodeNull(&confirmationTransactionID), decodeNull(&proofIndex), decodeNull(&proofTransactionID), decode(&fc.FileContract.Filesize), decode(&fc.FileContract.FileMerkleRoot), decode(&fc.FileContract.WindowStart), decode(&fc.FileContract.WindowEnd), decode(&fc.FileContract.Payout), decode(&fc.FileContract.UnlockHash), decode(&fc.FileContract.RevisionNumber)) + + if confirmationIndex != (types.ChainIndex{}) { + fc.ConfirmationIndex = &confirmationIndex + } + if confirmationTransactionID != (types.TransactionID{}) { + fc.ConfirmationTransactionID = &confirmationTransactionID + } + if proofIndex != (types.ChainIndex{}) { + fc.ProofIndex = &proofIndex + } + if proofTransactionID != (types.TransactionID{}) { + fc.ProofTransactionID = &proofTransactionID + } + + return +} + // Contracts implements explorer.Store. func (s *Store) Contracts(ids []types.FileContractID) (result []explorer.FileContract, err error) { err = s.transaction(func(tx *txn) error { @@ -34,24 +55,9 @@ func (s *Store) Contracts(ids []types.FileContractID) (result []explorer.FileCon var contractID int64 var fc explorer.FileContract - var confirmationIndex, proofIndex types.ChainIndex - var confirmationTransactionID, proofTransactionID types.TransactionID - if err := rows.Scan(&contractID, decode(&fc.StateElement.ID), decode(&fc.StateElement.LeafIndex), &fc.Resolved, &fc.Valid, decodeNull(&confirmationIndex), decodeNull(&confirmationTransactionID), decodeNull(&proofIndex), decodeNull(&proofTransactionID), decode(&fc.FileContract.Filesize), decode(&fc.FileContract.FileMerkleRoot), decode(&fc.FileContract.WindowStart), decode(&fc.FileContract.WindowEnd), decode(&fc.FileContract.Payout), decode(&fc.FileContract.UnlockHash), decode(&fc.FileContract.RevisionNumber)); err != nil { - return fmt.Errorf("failed to scan transaction: %w", err) - } - - if confirmationIndex != (types.ChainIndex{}) { - fc.ConfirmationIndex = &confirmationIndex - } - if confirmationTransactionID != (types.TransactionID{}) { - fc.ConfirmationTransactionID = &confirmationTransactionID - } - - if proofIndex != (types.ChainIndex{}) { - fc.ProofIndex = &proofIndex - } - if proofTransactionID != (types.TransactionID{}) { - fc.ProofTransactionID = &proofTransactionID + contractID, fc, err := scanFileContract(rows) + if err != nil { + return fmt.Errorf("failed to scan file contract: %w", err) } idContract[contractID] = fc @@ -75,6 +81,67 @@ func (s *Store) Contracts(ids []types.FileContractID) (result []explorer.FileCon return } +// ContractRevisions implements explorer.Store. +func (s *Store) ContractRevisions(id types.FileContractID) (revisions []types.FileContractElement, err error) { + err = s.transaction(func(tx *txn) error { + query := `SELECT fc.id, fc.contract_id, fc.leaf_index, fc.resolved, fc.valid, rev.confirmation_index, rev.confirmation_transaction_id, rev.proof_index, rev.proof_transaction_id, fc.filesize, fc.file_merkle_root, fc.window_start, fc.window_end, fc.payout, fc.unlock_hash, fc.revision_number + FROM file_contract_elements fc + LEFT JOIN last_contract_revision rev ON (rev.contract_element_id = fc.id) + WHERE fc.contract_id = ? + ORDER BY fc.revision_number ASC` + rows, err := tx.Query(query, encode(id)) + if err != nil { + return err + } + defer rows.Close() + + // fetch revisions + type fce struct { + ID int64 + types.FileContractElement + } + var fces []fce + var contractIDs []int64 + for rows.Next() { + contractID, fc, err := scanFileContract(rows) + if err != nil { + return fmt.Errorf("failed to scan file contract: %w", err) + } + + fces = append(fces, fce{ID: contractID, FileContractElement: fc.FileContractElement}) + contractIDs = append(contractIDs, contractID) + } + + // fetch corresponding outputs + proofOutputs, err := fileContractOutputs(tx, contractIDs) + if err != nil { + return fmt.Errorf("failed to get file contract outputs: %w", err) + } + + // merge outputs into revisions + revisions = make([]types.FileContractElement, len(fces)) + for i, revision := range fces { + output, found := proofOutputs[revision.ID] + if !found { + // contracts always have outputs + return fmt.Errorf("missing proof outputs for contract %v", contractIDs[i]) + } + revisions[i].FileContract.ValidProofOutputs = output.valid + revisions[i].FileContract.MissedProofOutputs = output.missed + } + + for i, fce := range fces { + revisions[i] = fce.FileContractElement + } + + if len(revisions) == 0 { + return explorer.ErrContractNotFound + } + return nil + }) + return +} + // ContractsKey implements explorer.Store. func (s *Store) ContractsKey(key types.PublicKey) (result []explorer.FileContract, err error) { err = s.transaction(func(tx *txn) error { @@ -91,27 +158,9 @@ func (s *Store) ContractsKey(key types.PublicKey) (result []explorer.FileContrac var contractIDs []int64 idContract := make(map[int64]explorer.FileContract) for rows.Next() { - var contractID int64 - var fc explorer.FileContract - - var confirmationIndex, proofIndex types.ChainIndex - var confirmationTransactionID, proofTransactionID types.TransactionID - if err := rows.Scan(&contractID, decode(&fc.StateElement.ID), decode(&fc.StateElement.LeafIndex), &fc.Resolved, &fc.Valid, decodeNull(&confirmationIndex), decodeNull(&confirmationTransactionID), decodeNull(&proofIndex), decodeNull(&proofTransactionID), decode(&fc.FileContract.Filesize), decode(&fc.FileContract.FileMerkleRoot), decode(&fc.FileContract.WindowStart), decode(&fc.FileContract.WindowEnd), decode(&fc.FileContract.Payout), decode(&fc.FileContract.UnlockHash), decode(&fc.FileContract.RevisionNumber)); err != nil { - return fmt.Errorf("failed to scan transaction: %w", err) - } - - if confirmationIndex != (types.ChainIndex{}) { - fc.ConfirmationIndex = &confirmationIndex - } - if confirmationTransactionID != (types.TransactionID{}) { - fc.ConfirmationTransactionID = &confirmationTransactionID - } - - if proofIndex != (types.ChainIndex{}) { - fc.ProofIndex = &proofIndex - } - if proofTransactionID != (types.TransactionID{}) { - fc.ProofTransactionID = &proofTransactionID + contractID, fc, err := scanFileContract(rows) + if err != nil { + return fmt.Errorf("failed to scan file contract: %w", err) } idContract[contractID] = fc diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 6f7bd7a7..b34ae8d8 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -75,6 +75,7 @@ CREATE INDEX siafund_elements_address_spent_index ON siafund_elements(address, s CREATE TABLE file_contract_elements ( id INTEGER PRIMARY KEY, + block_id BLOB REFERENCES blocks(id) ON DELETE CASCADE NOT NULL, contract_id BLOB NOT NULL, leaf_index BLOB NOT NULL, @@ -95,7 +96,6 @@ CREATE INDEX file_contract_elements_contract_id_index ON file_contract_elements( CREATE TABLE last_contract_revision ( contract_id BLOB PRIMARY KEY NOT NULL, - block_id BLOB REFERENCES blocks(id) ON DELETE CASCADE NOT NULL, ed25519_renter_key BLOB, ed25519_host_key BLOB, @@ -106,7 +106,7 @@ CREATE TABLE last_contract_revision ( proof_index BLOB, proof_transaction_id BLOB REFERENCES transactions(transaction_id), - contract_element_id INTEGER UNIQUE REFERENCES file_contract_elements(id) NOT NULL + contract_element_id INTEGER UNIQUE REFERENCES file_contract_elements(id) ON DELETE CASCADE NOT NULL ); CREATE TABLE file_contract_valid_proof_outputs ( diff --git a/persist/sqlite/transactions.go b/persist/sqlite/transactions.go index bfe8cee7..7d73c017 100644 --- a/persist/sqlite/transactions.go +++ b/persist/sqlite/transactions.go @@ -431,8 +431,8 @@ ORDER BY transaction_order ASC` } type transactionID struct { - id types.TransactionID - order int + id types.TransactionID + dbID int64 } // blockTransactionIDs returns the database ID for each transaction in the @@ -450,12 +450,12 @@ WHERE block_id = ? ORDER BY block_order ASC`, encode(blockID)) idMap = make(map[int64]transactionID) for rows.Next() { var dbID int64 - var blockOrder int + var blockOrder int64 var txnID types.TransactionID if err := rows.Scan(&dbID, &blockOrder, decode(&txnID)); err != nil { return nil, fmt.Errorf("failed to scan block transaction: %w", err) } - idMap[dbID] = transactionID{id: txnID, order: blockOrder} + idMap[blockOrder] = transactionID{id: txnID, dbID: dbID} } return } @@ -498,14 +498,14 @@ func transactionDatabaseIDs(tx *txn, txnIDs []types.TransactionID) (dbIDs map[in return result } - query := `SELECT id, transaction_id FROM transactions WHERE transaction_id IN (` + queryPlaceHolders(len(txnIDs)) + `)` + query := `SELECT id, transaction_id FROM transactions WHERE transaction_id IN (` + queryPlaceHolders(len(txnIDs)) + `) ORDER BY id` rows, err := tx.Query(query, encodedIDs(txnIDs)...) if err != nil { return nil, err } defer rows.Close() - i := 0 + var i int64 dbIDs = make(map[int64]transactionID) for rows.Next() { var dbID int64 @@ -513,7 +513,7 @@ func transactionDatabaseIDs(tx *txn, txnIDs []types.TransactionID) (dbIDs map[in if err := rows.Scan(&dbID, decode(&txnID)); err != nil { return nil, fmt.Errorf("failed to scan transaction: %w", err) } - dbIDs[dbID] = transactionID{id: txnID, order: i} + dbIDs[i] = transactionID{id: txnID, dbID: dbID} i++ } return @@ -521,8 +521,8 @@ func transactionDatabaseIDs(tx *txn, txnIDs []types.TransactionID) (dbIDs map[in func getTransactions(tx *txn, idMap map[int64]transactionID) ([]explorer.Transaction, error) { dbIDs := make([]int64, len(idMap)) - for dbID, id := range idMap { - dbIDs[id.order] = dbID + for order, id := range idMap { + dbIDs[order] = id.dbID } txnArbitraryData, err := transactionArbitraryData(tx, dbIDs) @@ -576,9 +576,9 @@ func getTransactions(tx *txn, idMap map[int64]transactionID) ([]explorer.Transac } var results []explorer.Transaction - for _, dbID := range dbIDs { + for order, dbID := range dbIDs { txn := explorer.Transaction{ - ID: idMap[dbID].id, + ID: idMap[int64(order)].id, SiacoinInputs: txnSiacoinInputs[dbID], SiacoinOutputs: txnSiacoinOutputs[dbID], SiafundInputs: txnSiafundInputs[dbID],