diff --git a/api/client.go b/api/client.go index 9a1ba1a5..6f1d6967 100644 --- a/api/client.go +++ b/api/client.go @@ -97,3 +97,15 @@ func (c *Client) AddressBalance(address types.Address) (resp AddressBalanceRespo err = c.c.GET(fmt.Sprintf("/explorer/addresses/%s/balance", address), &resp) return } + +// Contract returns the file contract with the specified ID. +func (c *Client) Contract(id types.FileContractID) (resp explorer.FileContract, err error) { + err = c.c.GET(fmt.Sprintf("/explorer/contracts/%s", id), &resp) + return +} + +// Contracts returns the transactions with the specified IDs. +func (c *Client) Contracts(ids []types.FileContractID) (resp []explorer.FileContract, err error) { + err = c.c.POST("/explorer/contracts", ids, &resp) + return +} diff --git a/api/server.go b/api/server.go index 263bc1bb..adafa9f0 100644 --- a/api/server.go +++ b/api/server.go @@ -47,9 +47,18 @@ type ( Balance(address types.Address) (sc types.Currency, sf uint64, err error) UnspentSiacoinOutputs(address types.Address, limit, offset uint64) ([]explorer.SiacoinOutput, error) UnspentSiafundOutputs(address types.Address, limit, offset uint64) ([]explorer.SiafundOutput, error) + Contracts(ids []types.FileContractID) (result []explorer.FileContract, err error) } ) +const ( + maxIDs = 5000 +) + +var ( + errTooManyIDs = fmt.Errorf("too many IDs provided (provide less than %d)", maxIDs) +) + type server struct { cm ChainManager e Explorer @@ -171,11 +180,6 @@ func (s *server) explorerTransactionsIDHandler(jc jape.Context) { } func (s *server) explorerTransactionsHandler(jc jape.Context) { - const ( - maxIDs = 5000 - ) - errTooManyIDs := fmt.Errorf("too many IDs provided (provide less than %d)", maxIDs) - var ids []types.TransactionID if jc.Decode(&ids) != nil { return @@ -235,6 +239,39 @@ func (s *server) explorerAddressessAddressBalanceHandler(jc jape.Context) { }) } +func (s *server) explorerContractIDHandler(jc jape.Context) { + errNotFound := errors.New("no contract found") + + var id types.FileContractID + if jc.DecodeParam("id", &id) != nil { + return + } + fcs, err := s.e.Contracts([]types.FileContractID{id}) + if jc.Check("failed to get contract", err) != nil { + return + } else if len(fcs) == 0 { + jc.Error(errNotFound, http.StatusNotFound) + return + } + jc.Encode(fcs[0]) +} + +func (s *server) explorerContractsHandler(jc jape.Context) { + var ids []types.FileContractID + if jc.Decode(&ids) != nil { + return + } else if len(ids) > maxIDs { + jc.Error(errTooManyIDs, http.StatusBadRequest) + return + } + + fcs, err := s.e.Contracts(ids) + if jc.Check("failed to get contracts", err) != nil { + return + } + jc.Encode(fcs) +} + // NewServer returns an HTTP handler that serves the explored API. func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { srv := server{ @@ -257,5 +294,7 @@ func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { "POST /explorer/transactions": srv.explorerTransactionsHandler, "GET /explorer/addresses/:address/utxos": srv.explorerAddressessAddressUtxosHandler, "GET /explorer/addresses/:address/balance": srv.explorerAddressessAddressBalanceHandler, + "GET /explorer/contracts/:id": srv.explorerContractIDHandler, + "POST /explorer/contracts": srv.explorerContractsHandler, }) } diff --git a/explorer/explorer.go b/explorer/explorer.go index 601c696a..75a0dd47 100644 --- a/explorer/explorer.go +++ b/explorer/explorer.go @@ -17,6 +17,7 @@ type Store interface { UnspentSiacoinOutputs(address types.Address, limit, offset uint64) ([]SiacoinOutput, error) UnspentSiafundOutputs(address types.Address, limit, offset uint64) ([]SiafundOutput, error) Balance(address types.Address) (sc types.Currency, sf uint64, err error) + Contracts(ids []types.FileContractID) (result []FileContract, err error) MerkleProof(leafIndex uint64) ([]types.Hash256, error) } @@ -72,3 +73,8 @@ func (e *Explorer) UnspentSiafundOutputs(address types.Address, limit, offset ui func (e *Explorer) Balance(address types.Address) (sc types.Currency, sf uint64, err error) { return e.s.Balance(address) } + +// Contracts returns the contracts with the specified IDs. +func (e *Explorer) Contracts(ids []types.FileContractID) (result []FileContract, err error) { + return e.s.Contracts(ids) +} diff --git a/persist/sqlite/contracts.go b/persist/sqlite/contracts.go new file mode 100644 index 00000000..84b09953 --- /dev/null +++ b/persist/sqlite/contracts.go @@ -0,0 +1,41 @@ +package sqlite + +import ( + "fmt" + + "go.sia.tech/core/types" + "go.sia.tech/explored/explorer" +) + +// Contracts implements explorer.Store. +func (s *Store) Contracts(ids []types.FileContractID) (result []explorer.FileContract, err error) { + encodedIDs := func(ids []types.FileContractID) []any { + result := make([]any, len(ids)) + for i, id := range ids { + result[i] = dbEncode(id) + } + return result + } + + err = s.transaction(func(tx txn) error { + query := `SELECT fc1.contract_id, fc1.leaf_index, fc1.merkle_proof, fc1.resolved, fc1.valid, fc1.filesize, fc1.file_merkle_root, fc1.window_start, fc1.window_end, fc1.payout, fc1.unlock_hash, fc1.revision_number + FROM file_contract_elements fc1 + WHERE fc1.contract_id IN (` + queryPlaceHolders(len(ids)) + `) + AND fc1.revision_number = (SELECT max(revision_number) FROM file_contract_elements fc2 WHERE fc2.contract_id = fc1.contract_id)` + rows, err := tx.Query(query, encodedIDs(ids)...) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var fc explorer.FileContract + if err := rows.Scan(dbDecode(&fc.StateElement.ID), dbDecode(&fc.StateElement.LeafIndex), dbDecode(&fc.StateElement.MerkleProof), &fc.Resolved, &fc.Valid, &fc.Filesize, dbDecode(&fc.FileMerkleRoot), &fc.WindowStart, &fc.WindowEnd, dbDecode(&fc.Payout), dbDecode(&fc.UnlockHash), &fc.RevisionNumber); err != nil { + return fmt.Errorf("failed to scan transaction: %w", err) + } + result = append(result, fc) + } + return nil + }) + return +} diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 88ab9dfe..b84bc9d9 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -75,6 +75,8 @@ CREATE TABLE file_contract_elements ( UNIQUE(contract_id, revision_number) ); +CREATE INDEX file_contract_elements_contract_id_index ON file_contract_elements(contract_id); + CREATE TABLE file_contract_valid_proof_outputs ( contract_id INTEGER REFERENCES file_contract_elements(id) ON DELETE CASCADE NOT NULL, contract_order INTEGER NOT NULL, diff --git a/persist/sqlite/transactions.go b/persist/sqlite/transactions.go index 18975e46..13f58227 100644 --- a/persist/sqlite/transactions.go +++ b/persist/sqlite/transactions.go @@ -169,7 +169,7 @@ ORDER BY ts.transaction_order DESC` func transactionFileContractRevisions(tx txn, txnIDs []int64) (map[int64][]explorer.FileContractRevision, error) { query := `SELECT ts.transaction_id, ts.parent_id, ts.unlock_conditions, fc.contract_id, fc.leaf_index, fc.merkle_proof, fc.resolved, fc.valid, 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 -INNER JOIN transaction_file_contracts ts ON (ts.contract_id = fc.id) +INNER JOIN transaction_file_contract_revisions ts ON (ts.contract_id = fc.id) WHERE ts.transaction_id IN (` + queryPlaceHolders(len(txnIDs)) + `) ORDER BY ts.transaction_order DESC` rows, err := tx.Query(query, queryArgs(txnIDs)...)