diff --git a/persist/sqlite/consensus.go b/persist/sqlite/consensus.go index b1330b55..752922a2 100644 --- a/persist/sqlite/consensus.go +++ b/persist/sqlite/consensus.go @@ -556,7 +556,7 @@ func deleteBlock(tx *txn, bid types.BlockID) error { return err } -func addFileContractElements(tx *txn, bid types.BlockID, fces []explorer.FileContractUpdate) (map[explorer.DBFileContract]int64, error) { +func addFileContractElements(tx *txn, b types.Block, fces []explorer.FileContractUpdate) (map[explorer.DBFileContract]int64, error) { stmt, err := tx.Prepare(`INSERT INTO file_contract_elements(block_id, contract_id, leaf_index, resolved, valid, filesize, file_merkle_root, window_start, window_end, payout, unlock_hash, revision_number) VALUES (?, ?, ?, FALSE, TRUE, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (contract_id, revision_number) @@ -575,28 +575,69 @@ func addFileContractElements(tx *txn, bid types.BlockID, fces []explorer.FileCon return nil, fmt.Errorf("addFileContractElements: failed to prepare last_contract_revision statement: %w", err) } - var updateErr error fcDBIds := make(map[explorer.DBFileContract]int64) - for _, update := range fces { - fce := update.FileContractElement - - fc := &fce.FileContract - if update.Revision != nil { - fc = &update.Revision.FileContract - } - + addFC := func(fcID types.FileContractID, leafIndex uint64, fc types.FileContract, resolved, valid, lastRevision bool) error { var dbID int64 - err := stmt.QueryRow(encode(bid), encode(fce.StateElement.ID), encode(fce.StateElement.LeafIndex), fc.Filesize, encode(fc.FileMerkleRoot), fc.WindowStart, fc.WindowEnd, encode(fc.Payout), encode(fc.UnlockHash), fc.RevisionNumber, update.Resolved, update.Valid, encode(fce.StateElement.LeafIndex)).Scan(&dbID) + 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), fc.RevisionNumber, resolved, valid, encode(leafIndex)).Scan(&dbID) if err != nil { - return nil, fmt.Errorf("addFileContractElements: failed to execute file_contract_elements statement: %w", err) + return fmt.Errorf("failed to execute file_contract_elements statement: %w", err) } - if _, err := revisionStmt.Exec(encode(fce.StateElement.ID), dbID, dbID); err != nil { - return nil, fmt.Errorf("addFileContractElements: failed to update last revision number: %w", err) + // 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 { + return fmt.Errorf("failed to update last revision number: %w", err) + } + } + + fcDBIds[explorer.DBFileContract{ID: fcID, RevisionNumber: fc.RevisionNumber}] = dbID + return nil + } + + var updateErr error + for _, update := range fces { + fce := &update.FileContractElement + if update.Revision != nil { + fce = update.Revision + } + + if err := addFC( + types.FileContractID(fce.StateElement.ID), + fce.StateElement.LeafIndex, + fce.FileContract, + update.Resolved, + update.Valid, + true, + ); err != nil { + return nil, fmt.Errorf("addFileContractElements: %w", err) + } + } + for _, txn := range b.Transactions { + for j, fc := range txn.FileContracts { + fcID := txn.FileContractID(j) + dbFC := explorer.DBFileContract{ID: txn.FileContractID(j), RevisionNumber: fc.RevisionNumber} + if _, exists := fcDBIds[dbFC]; exists { + continue + } + + if err := addFC(fcID, 0, fc, false, true, false); err != nil { + return nil, fmt.Errorf("addFileContractElements: %w", err) + } } + for _, fcr := range txn.FileContractRevisions { + fc := fcr.FileContract + dbFC := explorer.DBFileContract{ID: fcr.ParentID, RevisionNumber: fc.RevisionNumber} + if _, exists := fcDBIds[dbFC]; exists { + continue + } - fcDBIds[explorer.DBFileContract{ID: types.FileContractID(fce.StateElement.ID), RevisionNumber: fc.RevisionNumber}] = dbID + if err := addFC(fcr.ParentID, 0, fc, false, true, false); err != nil { + return nil, fmt.Errorf("addFileContractElements: %w", err) + } + } } + return fcDBIds, updateErr } @@ -629,7 +670,7 @@ func (ut *updateTx) ApplyIndex(state explorer.UpdateState) error { return fmt.Errorf("ApplyIndex: failed to update balances: %w", err) } - fcDBIds, err := addFileContractElements(ut.tx, state.Block.ID(), state.FileContractElements) + fcDBIds, err := addFileContractElements(ut.tx, state.Block, state.FileContractElements) if err != nil { return fmt.Errorf("v: failed to add file contracts: %w", err) } @@ -664,7 +705,7 @@ func (ut *updateTx) RevertIndex(state explorer.UpdateState) error { return fmt.Errorf("RevertIndex: failed to update siafund output state: %w", err) } else if err := updateBalances(ut.tx, state.Index.Height, state.SpentSiacoinElements, state.NewSiacoinElements, state.SpentSiafundElements, state.NewSiafundElements); err != nil { return fmt.Errorf("RevertIndex: failed to update balances: %w", err) - } else if _, err := addFileContractElements(ut.tx, state.Block.ID(), state.FileContractElements); err != nil { + } else if _, err := addFileContractElements(ut.tx, state.Block, state.FileContractElements); err != nil { return fmt.Errorf("RevertIndex: failed to update file contract state: %w", err) } else if err := deleteBlock(ut.tx, state.Block.ID()); err != nil { return fmt.Errorf("RevertIndex: failed to delete block: %w", err) diff --git a/persist/sqlite/consensus_test.go b/persist/sqlite/consensus_test.go index ad66a09b..c46f4481 100644 --- a/persist/sqlite/consensus_test.go +++ b/persist/sqlite/consensus_test.go @@ -861,6 +861,238 @@ func TestFileContract(t *testing.T) { } } +func TestEphemeralFileContract(t *testing.T) { + log := zaptest.NewLogger(t) + dir := t.TempDir() + + db, err := sqlite.OpenDatabase(filepath.Join(dir, "explored.sqlite3"), log.Named("sqlite3")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + bdb, err := coreutils.OpenBoltChainDB(filepath.Join(dir, "consensus.db")) + if err != nil { + t.Fatal(err) + } + defer bdb.Close() + + pk1 := types.GeneratePrivateKey() + addr1 := types.StandardUnlockHash(pk1.PublicKey()) + + renterPrivateKey := types.GeneratePrivateKey() + renterPublicKey := renterPrivateKey.PublicKey() + + hostPrivateKey := types.GeneratePrivateKey() + hostPublicKey := hostPrivateKey.PublicKey() + + giftSC := types.Siacoins(1000) + network, genesisBlock := testV1Network(addr1, giftSC, 0) + store, genesisState, err := chain.NewDBStore(bdb, network, genesisBlock) + if err != nil { + t.Fatal(err) + } + + cm := chain.NewManager(store, genesisState) + + scOutputID := genesisBlock.Transactions[0].SiacoinOutputID(0) + unlockConditions := types.StandardUnlockConditions(pk1.PublicKey()) + + signTxn := func(txn *types.Transaction) { + appendSig := func(key types.PrivateKey, pubkeyIndex uint64, parentID types.Hash256) { + sig := key.SignHash(cm.TipState().WholeSigHash(*txn, parentID, pubkeyIndex, 0, nil)) + txn.Signatures = append(txn.Signatures, types.TransactionSignature{ + ParentID: parentID, + CoveredFields: types.CoveredFields{WholeTransaction: true}, + PublicKeyIndex: pubkeyIndex, + Signature: sig[:], + }) + } + for i := range txn.SiacoinInputs { + appendSig(pk1, 0, types.Hash256(txn.SiacoinInputs[i].ParentID)) + } + for i := range txn.SiafundInputs { + appendSig(pk1, 0, types.Hash256(txn.SiafundInputs[i].ParentID)) + } + for i := range txn.FileContractRevisions { + appendSig(renterPrivateKey, 0, types.Hash256(txn.FileContractRevisions[i].ParentID)) + appendSig(hostPrivateKey, 1, types.Hash256(txn.FileContractRevisions[i].ParentID)) + } + } + + checkFC := func(revision, resolved, valid bool, expected types.FileContract, got explorer.FileContract) { + check(t, "resolved state", resolved, got.Resolved) + check(t, "valid state", valid, got.Valid) + check(t, "filesize", expected.Filesize, got.Filesize) + check(t, "file merkle root", expected.FileMerkleRoot, got.FileMerkleRoot) + check(t, "window start", expected.WindowStart, got.WindowStart) + check(t, "window end", expected.WindowEnd, got.WindowEnd) + + // See core/types.FileContractRevision + // Essentially, a revision cannot change the total payout, so this value + // is replaced with a sentinel value of types.MaxCurrency in revisions + // if it is decoded. + if !revision { + check(t, "payout", expected.Payout, got.Payout) + } + + check(t, "unlock hash", expected.UnlockHash, got.UnlockHash) + check(t, "revision number", expected.RevisionNumber, got.RevisionNumber) + check(t, "valid proof outputs", len(expected.ValidProofOutputs), len(got.ValidProofOutputs)) + for i := range expected.ValidProofOutputs { + check(t, "valid proof output address", expected.ValidProofOutputs[i].Address, got.ValidProofOutputs[i].Address) + check(t, "valid proof output value", expected.ValidProofOutputs[i].Value, got.ValidProofOutputs[i].Value) + } + check(t, "missed proof outputs", len(expected.MissedProofOutputs), len(got.MissedProofOutputs)) + for i := range expected.MissedProofOutputs { + check(t, "missed proof output address", expected.MissedProofOutputs[i].Address, got.MissedProofOutputs[i].Address) + check(t, "missed proof output value", expected.MissedProofOutputs[i].Value, got.MissedProofOutputs[i].Value) + } + } + + windowStart := cm.Tip().Height + 10 + windowEnd := windowStart + 10 + fc := prepareContractFormation(renterPublicKey, hostPublicKey, types.Siacoins(1), types.Siacoins(1), windowStart, windowEnd, types.VoidAddress) + txn := types.Transaction{ + SiacoinInputs: []types.SiacoinInput{{ + ParentID: scOutputID, + UnlockConditions: unlockConditions, + }}, + SiacoinOutputs: []types.SiacoinOutput{{ + Address: addr1, + Value: giftSC.Sub(fc.Payout), + }}, + FileContracts: []types.FileContract{fc}, + } + fcID := txn.FileContractID(0) + signTxn(&txn) + + uc := types.UnlockConditions{ + PublicKeys: []types.UnlockKey{ + renterPublicKey.UnlockKey(), + hostPublicKey.UnlockKey(), + }, + SignaturesRequired: 2, + } + revisedFC1 := fc + revisedFC1.RevisionNumber++ + reviseTxn1 := types.Transaction{ + FileContractRevisions: []types.FileContractRevision{{ + ParentID: fcID, + UnlockConditions: uc, + FileContract: revisedFC1, + }}, + } + signTxn(&reviseTxn1) + + // Create a contract and revise it in the same block + if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), []types.Transaction{txn, reviseTxn1}, types.VoidAddress)}); err != nil { + t.Fatal(err) + } + syncDB(t, db, cm) + + // Explorer.Contracts should return latest revision + { + dbFCs, err := db.Contracts([]types.FileContractID{fcID}) + if err != nil { + t.Fatal(err) + } + check(t, "fcs", 1, len(dbFCs)) + checkFC(true, false, true, revisedFC1, dbFCs[0]) + } + + { + txns, err := db.Transactions([]types.TransactionID{txn.ID()}) + if err != nil { + t.Fatal(err) + } + check(t, "transactions", 1, len(txns)) + check(t, "file contracts", 1, len(txns[0].FileContracts)) + checkFC(true, false, true, fc, txns[0].FileContracts[0]) + } + + { + txns, err := db.Transactions([]types.TransactionID{reviseTxn1.ID()}) + if err != nil { + t.Fatal(err) + } + check(t, "transactions", 1, len(txns)) + check(t, "file contracts", 1, len(txns[0].FileContractRevisions)) + + fcr := txns[0].FileContractRevisions[0] + check(t, "parent id", txn.FileContractID(0), fcr.ParentID) + check(t, "unlock conditions", uc, fcr.UnlockConditions) + + checkFC(true, false, true, revisedFC1, fcr.FileContract) + } + + revisedFC2 := revisedFC1 + revisedFC2.RevisionNumber++ + reviseTxn2 := types.Transaction{ + FileContractRevisions: []types.FileContractRevision{{ + ParentID: fcID, + UnlockConditions: uc, + FileContract: revisedFC2, + }}, + } + signTxn(&reviseTxn2) + + revisedFC3 := revisedFC2 + revisedFC3.RevisionNumber++ + reviseTxn3 := types.Transaction{ + FileContractRevisions: []types.FileContractRevision{{ + ParentID: fcID, + UnlockConditions: uc, + FileContract: revisedFC3, + }}, + } + signTxn(&reviseTxn3) + + // Two more revisions of the same contract in the next block + if err := cm.AddBlocks([]types.Block{mineBlock(cm.TipState(), []types.Transaction{reviseTxn2, reviseTxn3}, types.VoidAddress)}); err != nil { + t.Fatal(err) + } + syncDB(t, db, cm) + + // Explorer.Contracts should return latest revision + { + dbFCs, err := db.Contracts([]types.FileContractID{fcID}) + if err != nil { + t.Fatal(err) + } + check(t, "fcs", 1, len(dbFCs)) + checkFC(true, false, true, revisedFC3, dbFCs[0]) + } + + { + txns, err := db.Transactions([]types.TransactionID{reviseTxn2.ID()}) + if err != nil { + t.Fatal(err) + } + check(t, "transactions", 1, len(txns)) + check(t, "file contracts", 1, len(txns[0].FileContractRevisions)) + + fcr := txns[0].FileContractRevisions[0] + check(t, "parent id", txn.FileContractID(0), fcr.ParentID) + check(t, "unlock conditions", uc, fcr.UnlockConditions) + checkFC(true, false, true, revisedFC2, fcr.FileContract) + } + + { + txns, err := db.Transactions([]types.TransactionID{reviseTxn3.ID()}) + if err != nil { + t.Fatal(err) + } + check(t, "transactions", 1, len(txns)) + check(t, "file contracts", 1, len(txns[0].FileContractRevisions)) + + fcr := txns[0].FileContractRevisions[0] + check(t, "parent id", txn.FileContractID(0), fcr.ParentID) + check(t, "unlock conditions", uc, fcr.UnlockConditions) + checkFC(true, false, true, revisedFC3, fcr.FileContract) + } +} + func TestRevertTip(t *testing.T) { log := zaptest.NewLogger(t) dir := t.TempDir() diff --git a/persist/sqlite/init.sql b/persist/sqlite/init.sql index 2f86df92..c46638d0 100644 --- a/persist/sqlite/init.sql +++ b/persist/sqlite/init.sql @@ -186,7 +186,7 @@ CREATE TABLE transaction_file_contract_revisions ( transaction_id INTEGER REFERENCES transactions(id) ON DELETE CASCADE NOT NULL, transaction_order INTEGER NOT NULL, contract_id INTEGER REFERENCES file_contract_elements(id) ON DELETE CASCADE NOT NULL, - parent_id BLOB UNIQUE NOT NULL, + parent_id BLOB NOT NULL, unlock_conditions BLOB NOT NULL, UNIQUE(transaction_id, transaction_order) );