diff --git a/api/client.go b/api/client.go index 370a14ee..723560a4 100644 --- a/api/client.go +++ b/api/client.go @@ -116,6 +116,12 @@ func (c *Client) Contracts(ids []types.FileContractID) (resp []explorer.FileCont return } +// ContractsKey returns the contracts for a particular ed25519 key. +func (c *Client) ContractsKey(key types.PublicKey) (resp []explorer.FileContract, err error) { + err = c.c.GET(fmt.Sprintf("/explorer/pubkey/%s/contracts", key), &resp) + return +} + // Metrics returns the most recent metrics about Sia. func (c *Client) Metrics() (resp explorer.Metrics, err error) { err = c.c.GET("/explorer/metrics", &resp) diff --git a/api/server.go b/api/server.go index 3529cb39..6b92cb51 100644 --- a/api/server.go +++ b/api/server.go @@ -51,6 +51,7 @@ type ( UnspentSiafundOutputs(address types.Address, offset, limit uint64) ([]explorer.SiafundOutput, error) 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) } ) @@ -305,6 +306,23 @@ func (s *server) explorerContractIDHandler(jc jape.Context) { jc.Encode(fcs[0]) } +func (s *server) explorerContractKeyHandler(jc jape.Context) { + errNotFound := errors.New("no contract found") + + var key types.PublicKey + if jc.DecodeParam("key", &key) != nil { + return + } + fcs, err := s.e.ContractsKey(key) + if jc.Check("failed to get contracts", err) != nil { + return + } else if len(fcs) == 0 { + jc.Error(errNotFound, http.StatusNotFound) + return + } + jc.Encode(fcs) +} + func (s *server) explorerContractsHandler(jc jape.Context) { var ids []types.FileContractID if jc.Decode(&ids) != nil { @@ -347,6 +365,7 @@ func NewServer(e Explorer, cm ChainManager, s Syncer) http.Handler { "GET /explorer/addresses/:address/events": srv.explorerAddressessAddressEventsHandler, "GET /explorer/addresses/:address/balance": srv.explorerAddressessAddressBalanceHandler, "GET /explorer/contracts/:id": srv.explorerContractIDHandler, + "GET /explorer/pubkey/:key/contracts": srv.explorerContractKeyHandler, "POST /explorer/contracts": srv.explorerContractsHandler, }) } diff --git a/explorer/explorer.go b/explorer/explorer.go index 3f0e425f..1dfb4e7e 100644 --- a/explorer/explorer.go +++ b/explorer/explorer.go @@ -42,6 +42,7 @@ type Store interface { AddressEvents(address types.Address, offset, limit uint64) (events []Event, err error) 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) } // Explorer implements a Sia explorer. @@ -168,3 +169,8 @@ func (e *Explorer) Balance(address types.Address) (sc types.Currency, immatureSC func (e *Explorer) Contracts(ids []types.FileContractID) (result []FileContract, err error) { return e.s.Contracts(ids) } + +// ContractsKey returns the contracts for a particular ed25519 key. +func (e *Explorer) ContractsKey(key types.PublicKey) (result []FileContract, err error) { + return e.s.ContractsKey(key) +} diff --git a/persist/sqlite/consensus.go b/persist/sqlite/consensus.go index def11692..e4aaba16 100644 --- a/persist/sqlite/consensus.go +++ b/persist/sqlite/consensus.go @@ -741,18 +741,50 @@ func addFileContractElements(tx *txn, b types.Block, fces []explorer.FileContrac } defer stmt.Close() - revisionStmt, err := tx.Prepare(`INSERT INTO last_contract_revision(contract_id, contract_element_id) - 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 = ?`) + DO UPDATE SET contract_element_id = ?, ed25519_renter_key = COALESCE(?, ed25519_renter_key), ed25519_host_key = COALESCE(?, ed25519_host_key)`) if err != nil { return nil, fmt.Errorf("addFileContractElements: failed to prepare last_contract_revision statement: %w", err) } defer revisionStmt.Close() + fcKeys := make(map[explorer.DBFileContract][2]types.PublicKey) + // populate fcKeys using revision UnlockConditions fields + for _, txn := range b.Transactions { + for _, fcr := range txn.FileContractRevisions { + fc := fcr.FileContract + uc := fcr.UnlockConditions + dbFC := explorer.DBFileContract{ID: fcr.ParentID, RevisionNumber: fc.RevisionNumber} + + // check for 2 ed25519 keys + ok := true + var result [2]types.PublicKey + for i := 0; i < 2; i++ { + // fewer than 2 keys + if i >= len(uc.PublicKeys) { + ok = false + break + } + + if uc.PublicKeys[i].Algorithm == types.SpecifierEd25519 { + result[i] = types.PublicKey(uc.PublicKeys[i].Key) + } else { + // not an ed25519 key + ok = false + } + } + if ok { + fcKeys[dbFC] = result + } + } + } + fcDBIds := make(map[explorer.DBFileContract]int64) 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(b.ID()), encode(fcID), encode(leafIndex), fc.Filesize, encode(fc.FileMerkleRoot), fc.WindowStart, 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) @@ -761,12 +793,18 @@ func addFileContractElements(tx *txn, b types.Block, fces []explorer.FileContrac // only update if it's the most recent revision which will come from // running ForEachFileContractElement on the update if lastRevision { - if _, err := revisionStmt.Exec(encode(fcID), dbID, dbID); err != nil { + var renterKey, hostKey []byte + if keys, ok := fcKeys[dbFC]; ok { + renterKey = encode(keys[0]).([]byte) + hostKey = encode(keys[1]).([]byte) + } + + 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) } } - fcDBIds[explorer.DBFileContract{ID: fcID, RevisionNumber: fc.RevisionNumber}] = dbID + fcDBIds[dbFC] = dbID return nil } diff --git a/persist/sqlite/consensus_test.go b/persist/sqlite/consensus_test.go index 1c6a7e62..6290d6eb 100644 --- a/persist/sqlite/consensus_test.go +++ b/persist/sqlite/consensus_test.go @@ -858,6 +858,21 @@ func TestFileContract(t *testing.T) { } syncDB(t, db, cm) + { + 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, true, fc, renterContracts[0]) + checkFC(false, true, fc, hostContracts[0]) + } + checkMetrics(t, db, explorer.Metrics{ Height: 2, Difficulty: cm.TipState().Difficulty, @@ -930,6 +945,21 @@ func TestFileContract(t *testing.T) { syncDB(t, db, cm) } + { + 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(true, false, fc, renterContracts[0]) + checkFC(true, false, fc, hostContracts[0]) + } + checkMetrics(t, db, explorer.Metrics{ Height: cm.Tip().Height, Difficulty: cm.TipState().Difficulty, @@ -1069,6 +1099,21 @@ func TestEphemeralFileContract(t *testing.T) { } syncDB(t, db, cm) + { + 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(true, false, true, revisedFC1, renterContracts[0]) + checkFC(true, false, true, revisedFC1, hostContracts[0]) + } + // Explorer.Contracts should return latest revision { dbFCs, err := db.Contracts([]types.FileContractID{fcID}) @@ -1142,6 +1187,21 @@ func TestEphemeralFileContract(t *testing.T) { checkFC(true, false, true, revisedFC3, dbFCs[0]) } + { + 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(true, false, true, revisedFC3, renterContracts[0]) + checkFC(true, false, true, revisedFC3, hostContracts[0]) + } + { txns, err := db.Transactions([]types.TransactionID{reviseTxn2.ID()}) if err != nil { diff --git a/persist/sqlite/contracts.go b/persist/sqlite/contracts.go index bb57e1af..f51ea073 100644 --- a/persist/sqlite/contracts.go +++ b/persist/sqlite/contracts.go @@ -7,16 +7,16 @@ import ( "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] = encode(id) - } - return result +func encodedIDs(ids []types.FileContractID) []any { + result := make([]any, len(ids)) + for i, id := range ids { + result[i] = encode(id) } + return result +} +// Contracts implements explorer.Store. +func (s *Store) Contracts(ids []types.FileContractID) (result []explorer.FileContract, err error) { err = s.transaction(func(tx *txn) error { query := `SELECT fc1.id, fc1.contract_id, fc1.leaf_index, 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 @@ -57,3 +57,46 @@ func (s *Store) Contracts(ids []types.FileContractID) (result []explorer.FileCon return } + +// ContractsKey implements explorer.Store. +func (s *Store) ContractsKey(key types.PublicKey) (result []explorer.FileContract, err error) { + err = s.transaction(func(tx *txn) error { + query := `SELECT fc1.id, fc1.contract_id, fc1.leaf_index, 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 + INNER JOIN last_contract_revision rev ON (rev.contract_element_id = fc1.id) + WHERE rev.ed25519_renter_key = ? OR rev.ed25519_host_key = ?` + rows, err := tx.Query(query, encode(key), encode(key)) + if err != nil { + return err + } + defer rows.Close() + + var contractIDs []int64 + idContract := make(map[int64]explorer.FileContract) + for rows.Next() { + var contractID int64 + var fc explorer.FileContract + if err := rows.Scan(&contractID, decode(&fc.StateElement.ID), decode(&fc.StateElement.LeafIndex), &fc.Resolved, &fc.Valid, &fc.Filesize, decode(&fc.FileMerkleRoot), &fc.WindowStart, &fc.WindowEnd, decode(&fc.Payout), decode(&fc.UnlockHash), decode(&fc.RevisionNumber)); err != nil { + return fmt.Errorf("failed to scan transaction: %w", err) + } + + idContract[contractID] = fc + contractIDs = append(contractIDs, contractID) + } + + proofOutputs, err := fileContractOutputs(tx, contractIDs) + if err != nil { + return fmt.Errorf("failed to get file contract outputs: %w", err) + } + for contractID, output := range proofOutputs { + fc := idContract[contractID] + fc.ValidProofOutputs = output.valid + fc.MissedProofOutputs = output.missed + result = append(result, fc) + } + + return nil + }) + + return +} diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 17bc22d6..9e9add26 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -96,6 +96,8 @@ CREATE INDEX file_contract_elements_contract_id_index ON file_contract_elements( CREATE TABLE last_contract_revision ( contract_id BLOB PRIMARY KEY NOT NULL, + ed25519_renter_key BLOB, + ed25519_host_key BLOB, contract_element_id INTEGER UNIQUE REFERENCES file_contract_elements(id) ON DELETE CASCADE NOT NULL );