diff --git a/cmd/hostd/node.go b/cmd/hostd/node.go index cafb3c00..a2bf8f19 100644 --- a/cmd/hostd/node.go +++ b/cmd/hostd/node.go @@ -140,12 +140,12 @@ func newNode(ctx context.Context, walletKey types.PrivateKey, ex *explorer.Explo // load the host identity hostKey := db.HostKey() - cm, err := chain.NewManager(cs) + cm, err := chain.NewManager(cs, tp) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create chain manager: %w", err) } - w, err := wallet.NewSingleAddressWallet(walletKey, cm, tp, db, logger.Named("wallet")) + w, err := wallet.NewSingleAddressWallet(walletKey, cm, db, logger.Named("wallet")) if err != nil { return nil, types.PrivateKey{}, fmt.Errorf("failed to create wallet: %w", err) } diff --git a/host/accounts/accounts_test.go b/host/accounts/accounts_test.go index e03e3bf6..b2ee8599 100644 --- a/host/accounts/accounts_test.go +++ b/host/accounts/accounts_test.go @@ -60,13 +60,13 @@ func TestCredit(t *testing.T) { tp := chain.NewTPool(stp) defer tp.Close() - cm, err := chain.NewManager(cs) + cm, err := chain.NewManager(cs, tp) if err != nil { t.Fatal(err) } defer cm.Close() - w, err := wallet.NewSingleAddressWallet(types.NewPrivateKeyFromSeed(frand.Bytes(32)), cm, tp, db, log.Named("wallet")) + w, err := wallet.NewSingleAddressWallet(types.NewPrivateKeyFromSeed(frand.Bytes(32)), cm, db, log.Named("wallet")) if err != nil { t.Fatal(err) } diff --git a/host/accounts/budget_test.go b/host/accounts/budget_test.go index 4b44dc3c..bd594c7b 100644 --- a/host/accounts/budget_test.go +++ b/host/accounts/budget_test.go @@ -97,13 +97,13 @@ func TestBudget(t *testing.T) { tp := chain.NewTPool(stp) defer tp.Close() - cm, err := chain.NewManager(cs) + cm, err := chain.NewManager(cs, tp) if err != nil { t.Fatal(err) } defer cm.Close() - w, err := wallet.NewSingleAddressWallet(types.NewPrivateKeyFromSeed(frand.Bytes(32)), cm, tp, db, log.Named("wallet")) + w, err := wallet.NewSingleAddressWallet(types.NewPrivateKeyFromSeed(frand.Bytes(32)), cm, db, log.Named("wallet")) if err != nil { t.Fatal(err) } diff --git a/host/contracts/actions.go b/host/contracts/actions.go index 74f0706e..db81701e 100644 --- a/host/contracts/actions.go +++ b/host/contracts/actions.go @@ -158,16 +158,17 @@ func (cm *ContractManager) handleContractAction(id types.FileContractID, height fee := cm.tpool.RecommendedFee().Mul64(1000) revisionTxn.MinerFees = append(revisionTxn.MinerFees, fee) - toSign, discard, err := cm.wallet.FundTransaction(&revisionTxn, fee) + toSign, release, err := cm.wallet.FundTransaction(&revisionTxn, fee) if err != nil { log.Error("failed to fund revision transaction", zap.Error(err)) return } - defer discard() if err := cm.wallet.SignTransaction(cs, &revisionTxn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() log.Error("failed to sign revision transaction", zap.Error(err)) return } else if err := cm.tpool.AcceptTransactionSet([]types.Transaction{revisionTxn}); err != nil { + release() log.Error("failed to broadcast revision transaction", zap.Error(err)) return } @@ -215,14 +216,12 @@ func (cm *ContractManager) handleContractAction(id types.FileContractID, height StorageProofs: []types.StorageProof{sp}, }, } - intermediateToSign, discard, err := cm.wallet.FundTransaction(&resolutionTxnSet[0], fee) + intermediateToSign, release, err := cm.wallet.FundTransaction(&resolutionTxnSet[0], fee) if err != nil { log.Error("failed to fund resolution transaction", zap.Error(err)) registerContractAlert(alerts.SeverityError, "Failed to fund resolution transaction", err) return } - defer discard() - // add the intermediate output to the proof transaction resolutionTxnSet[1].SiacoinInputs = append(resolutionTxnSet[1].SiacoinInputs, types.SiacoinInput{ ParentID: resolutionTxnSet[0].SiacoinOutputID(0), @@ -231,12 +230,15 @@ func (cm *ContractManager) handleContractAction(id types.FileContractID, height proofToSign := []types.Hash256{types.Hash256(resolutionTxnSet[1].SiacoinInputs[0].ParentID)} start = time.Now() if err := cm.wallet.SignTransaction(cs, &resolutionTxnSet[0], intermediateToSign, types.CoveredFields{WholeTransaction: true}); err != nil { // sign the intermediate transaction + release() log.Error("failed to sign resolution intermediate transaction", zap.Error(err)) return } else if err := cm.wallet.SignTransaction(cs, &resolutionTxnSet[1], proofToSign, types.CoveredFields{WholeTransaction: true}); err != nil { // sign the proof transaction + release() log.Error("failed to sign resolution transaction", zap.Error(err)) return } else if err := cm.tpool.AcceptTransactionSet(resolutionTxnSet); err != nil { // broadcast the transaction set + release() buf, _ := json.Marshal(resolutionTxnSet) log.Error("failed to broadcast resolution transaction set", zap.Error(err), zap.ByteString("transactionSet", buf)) registerContractAlert(alerts.SeverityError, "Failed to broadcast resolution transaction set", err) diff --git a/host/contracts/manager_test.go b/host/contracts/manager_test.go index 093eb5c8..3126d817 100644 --- a/host/contracts/manager_test.go +++ b/host/contracts/manager_test.go @@ -44,14 +44,15 @@ func formContract(renterKey, hostKey types.PrivateKey, start, end uint64, renter txn := types.Transaction{ FileContracts: []types.FileContract{contract}, } - toSign, discard, err := w.FundTransaction(&txn, formationCost.Add(hostPayout)) // we're funding both sides of the payout + toSign, release, err := w.FundTransaction(&txn, formationCost.Add(hostPayout)) // we're funding both sides of the payout if err != nil { return contracts.SignedRevision{}, fmt.Errorf("failed to fund transaction: %w", err) } - defer discard() if err := w.SignTransaction(state, &txn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() return contracts.SignedRevision{}, fmt.Errorf("failed to sign transaction: %w", err) } else if err := tp.AcceptTransactionSet([]types.Transaction{txn}); err != nil { + release() return contracts.SignedRevision{}, fmt.Errorf("failed to accept transaction set: %w", err) } revision := types.FileContractRevision{ diff --git a/host/settings/announce.go b/host/settings/announce.go index afc1fbb8..8d745498 100644 --- a/host/settings/announce.go +++ b/host/settings/announce.go @@ -56,15 +56,16 @@ func (m *ConfigManager) Announce() error { if err != nil { return fmt.Errorf("failed to fund transaction: %w", err) } - defer release() // sign the transaction err = m.wallet.SignTransaction(m.cm.TipState(), &txn, toSign, types.CoveredFields{WholeTransaction: true}) if err != nil { + release() return fmt.Errorf("failed to sign transaction: %w", err) } // broadcast the transaction err = m.tp.AcceptTransactionSet([]types.Transaction{txn}) if err != nil { + release() return fmt.Errorf("failed to broadcast transaction: %w", err) } m.log.Debug("broadcast announcement", zap.String("transactionID", txn.ID().String()), zap.String("netaddress", settings.NetAddress), zap.String("cost", minerFee.ExactString())) diff --git a/host/storage/storage_test.go b/host/storage/storage_test.go index 671a6a2d..b4717929 100644 --- a/host/storage/storage_test.go +++ b/host/storage/storage_test.go @@ -19,6 +19,7 @@ import ( "go.sia.tech/hostd/webhooks" "go.sia.tech/siad/modules/consensus" "go.sia.tech/siad/modules/gateway" + "go.sia.tech/siad/modules/transactionpool" "go.uber.org/zap/zaptest" "lukechampine.com/frand" ) @@ -60,11 +61,17 @@ func TestVolumeLoad(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } - defer cm.Close() defer cm.Close() webhookReporter, err := webhooks.NewManager(db, log.Named("webhooks")) @@ -170,11 +177,17 @@ func TestAddVolume(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } - defer cm.Close() defer cm.Close() webhookReporter, err := webhooks.NewManager(db, log.Named("webhooks")) @@ -243,11 +256,17 @@ func TestRemoveVolume(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } - defer cm.Close() defer cm.Close() // initialize the storage manager @@ -411,11 +430,17 @@ func TestRemoveCorrupt(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } - defer cm.Close() defer cm.Close() // initialize the storage manager @@ -611,12 +636,18 @@ func TestRemoveMissing(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } defer cm.Close() - defer cm.Close() // initialize the storage manager webhookReporter, err := webhooks.NewManager(db, log.Named("webhooks")) @@ -788,11 +819,17 @@ func TestVolumeConcurrency(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } - defer cm.Close() defer cm.Close() // initialize the storage manager @@ -948,11 +985,17 @@ func TestVolumeGrow(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } - defer cm.Close() defer cm.Close() // initialize the storage manager @@ -1067,7 +1110,14 @@ func TestVolumeShrink(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } @@ -1229,7 +1279,14 @@ func TestVolumeManagerReadWrite(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } @@ -1348,7 +1405,14 @@ func TestSectorCache(t *testing.T) { } default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + t.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { t.Fatal(err) } @@ -1477,7 +1541,14 @@ func BenchmarkVolumeManagerWrite(b *testing.B) { b.Fatal(err) default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + b.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { b.Fatal(err) } @@ -1552,7 +1623,14 @@ func BenchmarkNewVolume(b *testing.B) { b.Fatal(err) default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + b.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { b.Fatal(err) } @@ -1610,7 +1688,14 @@ func BenchmarkVolumeManagerRead(b *testing.B) { b.Fatal(err) default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + b.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { b.Fatal(err) } @@ -1687,7 +1772,14 @@ func BenchmarkVolumeRemove(b *testing.B) { b.Fatal(err) default: } - cm, err := chain.NewManager(cs) + + tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) + if err != nil { + b.Fatal(err) + } + defer tp.Close() + + cm, err := chain.NewManager(cs, chain.NewTPool(tp)) if err != nil { b.Fatal(err) } diff --git a/internal/chain/manager.go b/internal/chain/manager.go index 18a3f78e..209b5dfd 100644 --- a/internal/chain/manager.go +++ b/internal/chain/manager.go @@ -49,6 +49,7 @@ func convertToCore(siad encoding.SiaMarshaler, core types.DecoderFrom) { // A Manager manages the current state of the blockchain. type Manager struct { cs modules.ConsensusSet + tp *TransactionPool network *consensus.Network close chan struct{} @@ -57,6 +58,11 @@ type Manager struct { synced bool } +// PoolTransactions returns all transactions in the transaction pool +func (m *Manager) PoolTransactions() []types.Transaction { + return m.tp.Transactions() +} + // ProcessConsensusChange implements the modules.ConsensusSetSubscriber interface. func (m *Manager) ProcessConsensusChange(cc modules.ConsensusChange) { m.mu.Lock() @@ -151,7 +157,7 @@ func synced(timestamp stypes.Timestamp) bool { } // NewManager creates a new chain manager. -func NewManager(cs modules.ConsensusSet) (*Manager, error) { +func NewManager(cs modules.ConsensusSet, tp *TransactionPool) (*Manager, error) { height := cs.Height() block, ok := cs.BlockAtHeight(height) if !ok { @@ -160,6 +166,7 @@ func NewManager(cs modules.ConsensusSet) (*Manager, error) { n, _ := build.Network() m := &Manager{ cs: cs, + tp: tp, network: n, tip: consensus.State{ Network: n, diff --git a/internal/test/host.go b/internal/test/host.go index 3677ec80..18f79e52 100644 --- a/internal/test/host.go +++ b/internal/test/host.go @@ -194,7 +194,7 @@ func NewEmptyHost(privKey types.PrivateKey, dir string, node *Node, log *zap.Log return nil, fmt.Errorf("failed to create sql store: %w", err) } - wallet, err := wallet.NewSingleAddressWallet(privKey, node.cm, node.tp, db, log.Named("wallet")) + wallet, err := wallet.NewSingleAddressWallet(privKey, node.cm, db, log.Named("wallet")) if err != nil { return nil, fmt.Errorf("failed to create wallet: %w", err) } diff --git a/internal/test/node.go b/internal/test/node.go index 747bacae..fba4551a 100644 --- a/internal/test/node.go +++ b/internal/test/node.go @@ -88,15 +88,18 @@ func NewNode(dir string) (*Node, error) { if err := <-errCh; err != nil { return nil, fmt.Errorf("failed to create consensus set: %w", err) } - cm, err := chain.NewManager(cs) - if err != nil { - return nil, err - } tp, err := transactionpool.New(cs, g, filepath.Join(dir, "transactionpool")) if err != nil { return nil, fmt.Errorf("failed to create transaction pool: %w", err) } + ctp := chain.NewTPool(tp) + + cm, err := chain.NewManager(cs, ctp) + if err != nil { + return nil, err + } + m := NewMiner(cm) if err := cs.ConsensusSetSubscribe(m, modules.ConsensusChangeBeginning, nil); err != nil { return nil, fmt.Errorf("failed to subscribe miner to consensus set: %w", err) @@ -106,7 +109,7 @@ func NewNode(dir string) (*Node, error) { g: g, cs: cs, cm: cm, - tp: chain.NewTPool(tp), + tp: ctp, m: m, }, nil } diff --git a/internal/test/renter.go b/internal/test/renter.go index 6be7d0fd..076512fe 100644 --- a/internal/test/renter.go +++ b/internal/test/renter.go @@ -109,14 +109,15 @@ func (r *Renter) FormContract(ctx context.Context, hostAddr string, hostKey type if err != nil { return crhp2.ContractRevision{}, fmt.Errorf("failed to fund transaction: %w", err) } - defer release() if err := r.wallet.SignTransaction(cs, &formationTxn, toSign, explicitCoveredFields(formationTxn)); err != nil { + release() return crhp2.ContractRevision{}, fmt.Errorf("failed to sign transaction: %w", err) } revision, _, err := rhp2.RPCFormContract(ctx, t, r.privKey, []types.Transaction{formationTxn}) if err != nil { + release() return crhp2.ContractRevision{}, fmt.Errorf("failed to form contract: %w", err) } return revision, nil @@ -204,7 +205,7 @@ func NewRenter(privKey types.PrivateKey, dir string, node *Node, log *zap.Logger if err != nil { return nil, fmt.Errorf("failed to create sql store: %w", err) } - wallet, err := wallet.NewSingleAddressWallet(privKey, node.ChainManager(), node.TPool(), db, log.Named("wallet")) + wallet, err := wallet.NewSingleAddressWallet(privKey, node.ChainManager(), db, log.Named("wallet")) if err != nil { return nil, fmt.Errorf("failed to create wallet: %w", err) } diff --git a/internal/test/rhp/v3/rhp.go b/internal/test/rhp/v3/rhp.go index cd2f08ab..292382eb 100644 --- a/internal/test/rhp/v3/rhp.go +++ b/internal/test/rhp/v3/rhp.go @@ -463,7 +463,6 @@ func (s *Session) RenewContract(revision *rhp2.ContractRevision, hostAddr types. if err != nil { return rhp2.ContractRevision{}, nil, fmt.Errorf("failed to fund transaction: %w", err) } - defer release() clearingSigHash := hashFinalRevision(clearingRevision, renewal) renewReq := &rhp3.RPCRenewContractRequest{ @@ -472,13 +471,16 @@ func (s *Session) RenewContract(revision *rhp2.ContractRevision, hostAddr types. FinalRevisionSignature: renterKey.SignHash(clearingSigHash), } if err := stream.WriteResponse(renewReq); err != nil { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("failed to write renew request: %w", err) } var hostAdditions rhp3.RPCRenewContractHostAdditions if err := stream.ReadResponse(&hostAdditions, 4096); err != nil { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("failed to read host additions response: %w", err) } else if !s.hostKey.VerifyHash(clearingSigHash, hostAdditions.FinalRevisionSignature) { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("host final revision signature invalid") } // add the host's additions to the transaction set @@ -488,6 +490,7 @@ func (s *Session) RenewContract(revision *rhp2.ContractRevision, hostAddr types. // sign the transaction if err := s.w.SignTransaction(state, &renewTxn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("failed to sign transaction: %w", err) } @@ -506,13 +509,16 @@ func (s *Session) RenewContract(revision *rhp2.ContractRevision, hostAddr types. }, } if err := stream.WriteResponse(renterSigsResp); err != nil { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("failed to write renter signatures: %w", err) } var hostSigsResp rhp3.RPCRenewSignatures if err := stream.ReadResponse(&hostSigsResp, 4096); err != nil { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("failed to read host signatures: %w", err) } else if err := validateHostRevisionSignature(hostSigsResp.RevisionSignature, renewRevision.ParentID, renewSigHash, s.hostKey); err != nil { + release() return rhp2.ContractRevision{}, nil, fmt.Errorf("invalid host revision signature: %w", err) } return rhp2.ContractRevision{ diff --git a/internal/test/wallet.go b/internal/test/wallet.go index cfbda8eb..892afc98 100644 --- a/internal/test/wallet.go +++ b/internal/test/wallet.go @@ -44,10 +44,11 @@ func (w *Wallet) SendSiacoins(outputs []types.SiacoinOutput) (txn types.Transact if err != nil { return types.Transaction{}, fmt.Errorf("failed to fund transaction: %w", err) } - defer release() if err := w.SignTransaction(w.ChainManager().TipState(), &txn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() return txn, fmt.Errorf("failed to sign transaction: %w", err) } else if err := w.tp.AcceptTransactionSet([]types.Transaction{txn}); err != nil { + release() return txn, fmt.Errorf("failed to accept transaction set: %w", err) } return txn, nil @@ -63,7 +64,7 @@ func NewWallet(privKey types.PrivateKey, dir string, log *zap.Logger) (*Wallet, if err != nil { return nil, fmt.Errorf("failed to create sql store: %w", err) } - wallet, err := wallet.NewSingleAddressWallet(privKey, node.cm, node.tp, db, log.Named("wallet")) + wallet, err := wallet.NewSingleAddressWallet(privKey, node.cm, db, log.Named("wallet")) if err != nil { return nil, fmt.Errorf("failed to create wallet: %w", err) } diff --git a/rhp/v2/rpc.go b/rhp/v2/rpc.go index 25ebc005..2cfcb55f 100644 --- a/rhp/v2/rpc.go +++ b/rhp/v2/rpc.go @@ -158,7 +158,7 @@ func (sh *SessionHandler) rpcFormContract(s *session, log *zap.Logger) (contract // calculate the host's collateral and add the inputs to the transaction renterInputs, renterOutputs := len(formationTxn.SiacoinInputs), len(formationTxn.SiacoinOutputs) - toSign, discard, err := sh.wallet.FundTransaction(formationTxn, hostCollateral) + toSign, release, err := sh.wallet.FundTransaction(formationTxn, hostCollateral) if err != nil { remoteErr := ErrHostInternalError if errors.Is(err, wallet.ErrNotEnoughFunds) { @@ -167,7 +167,6 @@ func (sh *SessionHandler) rpcFormContract(s *session, log *zap.Logger) (contract s.t.WriteResponseErr(fmt.Errorf("failed to fund formation transaction: %w", remoteErr)) return contracts.Usage{}, fmt.Errorf("failed to fund formation transaction: %w", err) } - defer discard() // create an initial revision for the contract initialRevision := rhp.InitialRevision(formationTxn, hostPub.UnlockKey(), renterPub.UnlockKey()) @@ -180,14 +179,17 @@ func (sh *SessionHandler) rpcFormContract(s *session, log *zap.Logger) (contract Outputs: formationTxn.SiacoinOutputs[renterOutputs:], } if err := s.writeResponse(hostAdditionsResp, 30*time.Second); err != nil { + release() return contracts.Usage{}, fmt.Errorf("failed to write host additions: %w", err) } // read and validate the renter's signatures var renterSignaturesResp rhp2.RPCFormContractSignatures if err := s.readResponse(&renterSignaturesResp, 10*minMessageSize, 30*time.Second); err != nil { + release() return contracts.Usage{}, fmt.Errorf("failed to read renter signatures: %w", err) } else if err := validateRenterRevisionSignature(renterSignaturesResp.RevisionSignature, initialRevision.ParentID, sigHash, renterPub); err != nil { + release() err := fmt.Errorf("contract rejected: validation failed: %w", err) s.t.WriteResponseErr(err) return contracts.Usage{}, err @@ -198,9 +200,11 @@ func (sh *SessionHandler) rpcFormContract(s *session, log *zap.Logger) (contract // sign and broadcast the formation transaction if err = sh.wallet.SignTransaction(sh.cm.TipState(), formationTxn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() s.t.WriteResponseErr(ErrHostInternalError) return contracts.Usage{}, fmt.Errorf("failed to sign formation transaction: %w", err) } else if err = sh.tpool.AcceptTransactionSet(formationTxnSet); err != nil { + release() err = fmt.Errorf("failed to broadcast formation transaction: %w", err) buf, _ := json.Marshal(formationTxnSet) log.Error("failed to broadcast formation transaction", zap.Error(err), zap.String("txnset", string(buf))) @@ -325,7 +329,7 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) } renterInputs, renterOutputs := len(renewalTxn.SiacoinInputs), len(renewalTxn.SiacoinOutputs) - toSign, discard, err := sh.wallet.FundTransaction(&renewalTxn, lockedCollateral) + toSign, release, err := sh.wallet.FundTransaction(&renewalTxn, lockedCollateral) if err != nil { remoteErr := ErrHostInternalError if errors.Is(err, wallet.ErrNotEnoughFunds) { @@ -334,7 +338,6 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) s.t.WriteResponseErr(fmt.Errorf("failed to fund renewal transaction: %w", remoteErr)) return contracts.Usage{}, fmt.Errorf("failed to fund renewal transaction: %w", err) } - defer discard() // send the renter the host additions to the renewal txn hostAdditionsResp := &rhp2.RPCFormContractAdditions{ @@ -342,14 +345,17 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) Outputs: renewalTxn.SiacoinOutputs[renterOutputs:], } if err = s.writeResponse(hostAdditionsResp, 30*time.Second); err != nil { + release() return contracts.Usage{}, fmt.Errorf("failed to write host additions: %w", err) } // read the renter's signatures for the renewal var renterSigsResp rhp2.RPCRenewAndClearContractSignatures if err = s.readResponse(&renterSigsResp, minMessageSize, 30*time.Second); err != nil { + release() return contracts.Usage{}, fmt.Errorf("failed to read renter signatures: %w", err) } else if len(renterSigsResp.RevisionSignature.Signature) != 64 { + release() return contracts.Usage{}, fmt.Errorf("invalid renter signature length: %w", ErrInvalidRenterSignature) } @@ -357,6 +363,7 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) renewalTxn.Signatures = append(renewalTxn.Signatures, renterSigsResp.ContractSignatures...) // sign the transaction if err = sh.wallet.SignTransaction(state, &renewalTxn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() s.t.WriteResponseErr(ErrHostInternalError) return contracts.Usage{}, fmt.Errorf("failed to sign renewal transaction: %w", err) } @@ -368,6 +375,7 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) clearingRevSigHash := rhp.HashRevision(clearingRevision) // important: verify using the existing contract's renter key if !s.contract.RenterKey().VerifyHash(clearingRevSigHash, renterSigsResp.FinalRevisionSignature) { + release() err := fmt.Errorf("failed to verify clearing revision signature: %w", ErrInvalidRenterSignature) s.t.WriteResponseErr(err) return contracts.Usage{}, err @@ -377,6 +385,7 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) renewalSigHash := rhp.HashRevision(initialRevision) renterRenewalSig := *(*types.Signature)(renterSigsResp.RevisionSignature.Signature) if !renterKey.VerifyHash(renewalSigHash, renterRenewalSig) { + release() err := fmt.Errorf("failed to verify renewal revision signature: %w", ErrInvalidRenterSignature) s.t.WriteResponseErr(err) return contracts.Usage{}, err @@ -396,6 +405,7 @@ func (sh *SessionHandler) rpcRenewAndClearContract(s *session, log *zap.Logger) // broadcast the transaction renewalTxnSet = append(renewalParents, renewalTxn) if err = sh.tpool.AcceptTransactionSet(renewalTxnSet); err != nil { + release() err = fmt.Errorf("failed to broadcast renewal transaction: %w", err) s.t.WriteResponseErr(err) return contracts.Usage{}, err diff --git a/rhp/v2/rpc_test.go b/rhp/v2/rpc_test.go index baa6ebdb..4e421de7 100644 --- a/rhp/v2/rpc_test.go +++ b/rhp/v2/rpc_test.go @@ -175,18 +175,19 @@ func TestRenew(t *testing.T) { } cost := rhp2.ContractRenewalCost(state, renewed, settings.ContractPrice, types.ZeroCurrency, basePrice) - toSign, discard, err := renter.Wallet().FundTransaction(&renewalTxn, cost) + toSign, release, err := renter.Wallet().FundTransaction(&renewalTxn, cost) if err != nil { t.Fatal(err) } - defer discard() if err := renter.Wallet().SignTransaction(host.TipState(), &renewalTxn, toSign, wallet.ExplicitCoveredFields(renewalTxn)); err != nil { + release() t.Fatal(err) } renewal, _, err := session.RenewContract(context.Background(), []types.Transaction{renewalTxn}, settings.BaseRPCPrice) if err != nil { + release() t.Fatal(err) } @@ -285,18 +286,19 @@ func TestRenew(t *testing.T) { } cost := rhp2.ContractRenewalCost(state, renewed, settings.ContractPrice, types.ZeroCurrency, basePrice) - toSign, discard, err := renter.Wallet().FundTransaction(&renewalTxn, cost) + toSign, release, err := renter.Wallet().FundTransaction(&renewalTxn, cost) if err != nil { t.Fatal(err) } - defer discard() if err := renter.Wallet().SignTransaction(host.TipState(), &renewalTxn, toSign, wallet.ExplicitCoveredFields(renewalTxn)); err != nil { + release() t.Fatal(err) } // try to renew the contract without paying the remaining value, should fail if _, _, err := session.RenewContract(context.Background(), []types.Transaction{renewalTxn}, types.ZeroCurrency); err == nil { + release() t.Fatal("expected renewal to fail") } else if err := session.Close(); err != nil { t.Fatal(err) diff --git a/rhp/v3/rpc.go b/rhp/v3/rpc.go index 2000dcbe..6003466b 100644 --- a/rhp/v3/rpc.go +++ b/rhp/v3/rpc.go @@ -346,7 +346,6 @@ func (sh *SessionHandler) handleRPCRenew(s *rhp3.Stream, log *zap.Logger) (contr s.WriteResponseErr(fmt.Errorf("failed to fund renewal transaction: %w", remoteErr)) return contracts.Usage{}, fmt.Errorf("failed to fund renewal transaction: %w", err) } - defer release() hostAdditions := &rhp3.RPCRenewContractHostAdditions{ SiacoinInputs: renewalTxn.SiacoinInputs[renterInputs:], @@ -354,11 +353,13 @@ func (sh *SessionHandler) handleRPCRenew(s *rhp3.Stream, log *zap.Logger) (contr FinalRevisionSignature: signedClearingRevision.HostSignature, } if err := s.WriteResponse(hostAdditions); err != nil { + release() return contracts.Usage{}, fmt.Errorf("failed to write host additions: %w", err) } var renterSigsResp rhp3.RPCRenewSignatures if err := s.ReadRequest(&renterSigsResp, 10*maxRequestSize); err != nil { + release() return contracts.Usage{}, fmt.Errorf("failed to read renter signatures: %w", err) } @@ -366,6 +367,7 @@ func (sh *SessionHandler) handleRPCRenew(s *rhp3.Stream, log *zap.Logger) (contr renewalRevision := rhp.InitialRevision(&renewalTxn, hostUnlockKey, req.RenterKey) renewalSigHash := rhp.HashRevision(renewalRevision) if err := validateRenterRevisionSignature(renterSigsResp.RevisionSignature, renewalRevision.ParentID, renewalSigHash, renterKey); err != nil { + release() err := fmt.Errorf("failed to verify renter revision signature: %w", ErrInvalidRenterSignature) s.WriteResponseErr(err) return contracts.Usage{}, err @@ -400,11 +402,13 @@ func (sh *SessionHandler) handleRPCRenew(s *rhp3.Stream, log *zap.Logger) (contr // sign and broadcast the transaction if err := sh.wallet.SignTransaction(sh.chain.TipState(), &renewalTxn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() s.WriteResponseErr(fmt.Errorf("failed to sign renewal transaction: %w", ErrHostInternalError)) return contracts.Usage{}, fmt.Errorf("failed to sign renewal transaction: %w", err) } renewalTxnSet := append(parents, renewalTxn) if err := sh.tpool.AcceptTransactionSet(renewalTxnSet); err != nil { + release() err = fmt.Errorf("failed to broadcast renewal transaction: %w", err) s.WriteResponseErr(err) return contracts.Usage{}, err diff --git a/wallet/wallet.go b/wallet/wallet.go index ae1371a7..f6e1b131 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -2,6 +2,7 @@ package wallet import ( "bytes" + "context" "errors" "fmt" "sort" @@ -57,14 +58,10 @@ type ( ChainManager interface { TipState() consensus.State BlockAtHeight(height uint64) (types.Block, bool) + PoolTransactions() []types.Transaction Subscribe(subscriber modules.ConsensusSetSubscriber, ccID modules.ConsensusChangeID, cancel <-chan struct{}) error } - // A TransactionPool manages unconfirmed transactions. - TransactionPool interface { - Subscribe(subscriber modules.TransactionPoolSubscriber) - } - // A SiacoinElement is a SiacoinOutput along with its ID. SiacoinElement struct { types.SiacoinOutput @@ -96,24 +93,11 @@ type ( log *zap.Logger tg *threadgroup.ThreadGroup - mu sync.Mutex // protects the following fields - // tpoolTxns maps a transaction set ID to the transactions in that set - tpoolTxns map[modules.TransactionSetID][]Transaction - // tpoolUtxos maps a siacoin output ID to its corresponding siacoin - // element. It is used to track siacoin outputs that are currently in - // the transaction pool. - tpoolUtxos map[types.SiacoinOutputID]SiacoinElement - // tpoolSpent is a set of siacoin output IDs that are currently in the - // transaction pool. - tpoolSpent map[types.SiacoinOutputID]bool - // consensusLocked is a set of siacoin output IDs that are currently in - // the process of removal. Reduces a race-condition with UnspentUtxos - // causing "siacoin output does not exist errors." - consensusLocked map[types.SiacoinOutputID]bool + mu sync.Mutex // locked is a set of siacoin output IDs locked by FundTransaction. They - // will be released either by calling Release for unused transactions or - // being confirmed in a block. - locked map[types.SiacoinOutputID]bool + // will be released either by explicitly calling release for unused + // transactions or expiring after 3 hours. + locked map[types.SiacoinOutputID]time.Time } ) @@ -194,6 +178,36 @@ func transactionIsRelevant(txn types.Transaction, addr types.Address) bool { return false } +// isLocked returns whether an output is currently locked by FundTransaction. +func (sw *SingleAddressWallet) isLocked(id types.SiacoinOutputID) bool { + return sw.locked[id].After(time.Now()) +} + +func (sw *SingleAddressWallet) tpoolUTXOs() (relevant map[types.SiacoinOutputID]types.SiacoinElement, spent map[types.SiacoinOutputID]bool) { + txns := sw.cm.PoolTransactions() + relevant = make(map[types.SiacoinOutputID]types.SiacoinElement) + spent = make(map[types.SiacoinOutputID]bool) + for _, txn := range txns { + for _, sci := range txn.SiacoinInputs { + if sci.UnlockConditions.UnlockHash() == sw.addr { + spent[types.SiacoinOutputID(sci.ParentID)] = true + } + } + for i, sco := range txn.SiacoinOutputs { + if sco.Address == sw.addr { + outputID := txn.SiacoinOutputID(i) + relevant[outputID] = types.SiacoinElement{ + StateElement: types.StateElement{ + ID: types.Hash256(outputID), + }, + SiacoinOutput: sco, + } + } + } + } + return +} + // Close closes the wallet func (sw *SingleAddressWallet) Close() error { sw.tg.Stop() @@ -224,15 +238,21 @@ func (sw *SingleAddressWallet) Balance() (spendable, confirmed, unconfirmed type } sw.mu.Lock() defer sw.mu.Unlock() + + tpoolUTXOs, spent := sw.tpoolUTXOs() + for _, sco := range outputs { confirmed = confirmed.Add(sco.Value) - if !sw.locked[sco.ID] && !sw.tpoolSpent[sco.ID] { + if !sw.isLocked(sco.ID) && !spent[sco.ID] { spendable = spendable.Add(sco.Value) } } - for _, sco := range sw.tpoolUtxos { - unconfirmed = unconfirmed.Add(sco.Value) + for _, sco := range tpoolUTXOs { + if spent[types.SiacoinOutputID(sco.ID)] { + continue + } + unconfirmed = unconfirmed.Add(sco.SiacoinOutput.Value) } return } @@ -282,10 +302,12 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty return nil, nil, err } + _, tpoolSpent := sw.tpoolUTXOs() + // remove locked and spent outputs usableUTXOs := utxos[:0] for _, sce := range utxos { - if sw.locked[sce.ID] || sw.tpoolSpent[sce.ID] || sw.consensusLocked[sce.ID] { + if sw.isLocked(sce.ID) || tpoolSpent[types.SiacoinOutputID(sce.ID)] { continue } usableUTXOs = append(usableUTXOs, sce) @@ -348,7 +370,7 @@ func (sw *SingleAddressWallet) FundTransaction(txn *types.Transaction, amount ty UnlockConditions: types.StandardUnlockConditions(sw.priv.PublicKey()), }) toSign[i] = types.Hash256(sce.ID) - sw.locked[sce.ID] = true + sw.locked[sce.ID] = time.Now().Add(3 * time.Hour) } release := func() { @@ -392,117 +414,45 @@ func (sw *SingleAddressWallet) ScanHeight() uint64 { return atomic.LoadUint64(&sw.scanHeight) } -// ReceiveUpdatedUnconfirmedTransactions implements modules.TransactionPoolSubscriber. -func (sw *SingleAddressWallet) ReceiveUpdatedUnconfirmedTransactions(diff *modules.TransactionPoolDiff) { - done, err := sw.tg.Add() - if err != nil { - return - } - defer done() - - siacoinOutputs := make(map[types.SiacoinOutputID]SiacoinElement) - utxos, err := sw.store.UnspentSiacoinElements() - if err != nil { - return - } - for _, output := range utxos { - siacoinOutputs[output.ID] = output - } - +// UnconfirmedTransactions returns all unconfirmed transactions relevant to the +// wallet. +func (sw *SingleAddressWallet) UnconfirmedTransactions() ([]Transaction, error) { sw.mu.Lock() defer sw.mu.Unlock() - for id, output := range sw.tpoolUtxos { - siacoinOutputs[id] = output - } + tpoolUTXOs, _ := sw.tpoolUTXOs() - for _, txnsetID := range diff.RevertedTransactions { - txns, ok := sw.tpoolTxns[txnsetID] - if !ok { - continue - } - for _, txn := range txns { - for _, sci := range txn.Transaction.SiacoinInputs { - delete(sw.tpoolSpent, sci.ParentID) + var relevant []Transaction + txns := sw.cm.PoolTransactions() + for _, txn := range txns { + if transactionIsRelevant(txn, sw.addr) { + var inflow, outflow types.Currency + for _, out := range txn.SiacoinOutputs { + if out.Address == sw.addr { + inflow = inflow.Add(out.Value) + } } - for i := range txn.Transaction.SiacoinOutputs { - delete(sw.tpoolUtxos, txn.Transaction.SiacoinOutputID(i)) + for _, in := range txn.SiacoinInputs { + if in.UnlockConditions.UnlockHash() == sw.addr { + out, ok := tpoolUTXOs[types.SiacoinOutputID(in.ParentID)] + if !ok { + panic("missing utxo") + } + outflow = outflow.Add(out.SiacoinOutput.Value) + } } - } - delete(sw.tpoolTxns, txnsetID) - } - - currentHeight := sw.cm.TipState().Index.Height - - for _, txnset := range diff.AppliedTransactions { - var relevantTxns []Transaction - - txnLoop: - for _, stxn := range txnset.Transactions { - var relevant bool - var txn types.Transaction - convertToCore(stxn, &txn) - processed := Transaction{ - ID: txn.ID(), - Index: types.ChainIndex{ - Height: currentHeight + 1, - }, + relevant = append(relevant, Transaction{ + ID: txn.ID(), + Index: types.ChainIndex{}, Transaction: txn, + Inflow: inflow, + Outflow: outflow, Source: TxnSourceTransaction, Timestamp: time.Now(), - } - for _, sci := range txn.SiacoinInputs { - if sci.UnlockConditions.UnlockHash() != sw.addr { - continue - } - relevant = true - sw.tpoolSpent[sci.ParentID] = true - - output, ok := siacoinOutputs[sci.ParentID] - if !ok { - // note: happens during deep reorgs. Possibly a race - // condition in siad. Log and skip. - sw.log.Debug("tpool transaction unknown utxo", zap.Stringer("outputID", sci.ParentID), zap.Stringer("txnID", txn.ID())) - continue txnLoop - } - processed.Outflow = processed.Outflow.Add(output.Value) - } - - for i, sco := range txn.SiacoinOutputs { - if sco.Address != sw.addr { - continue - } - relevant = true - outputID := txn.SiacoinOutputID(i) - processed.Inflow = processed.Inflow.Add(sco.Value) - sce := SiacoinElement{ - ID: outputID, - SiacoinOutput: sco, - } - siacoinOutputs[outputID] = sce - sw.tpoolUtxos[outputID] = sce - } - - if relevant { - relevantTxns = append(relevantTxns, processed) - } - } - - if len(relevantTxns) != 0 { - sw.tpoolTxns[txnset.ID] = relevantTxns + }) } } -} - -// UnconfirmedTransactions returns all unconfirmed transactions relevant to the -// wallet. -func (sw *SingleAddressWallet) UnconfirmedTransactions() (txns []Transaction, _ error) { - sw.mu.Lock() - defer sw.mu.Unlock() - for _, txnset := range sw.tpoolTxns { - txns = append(txns, txnset...) - } - return + return relevant, nil } // ProcessConsensusChange implements modules.ConsensusSetSubscriber. @@ -590,14 +540,6 @@ func (sw *SingleAddressWallet) ProcessConsensusChange(cc modules.ConsensusChange } } - var locked []types.SiacoinOutputID - sw.mu.Lock() - for _, diff := range cc.SiacoinOutputDiffs { - sw.consensusLocked[types.SiacoinOutputID(diff.ID)] = true - locked = append(locked, types.SiacoinOutputID(diff.ID)) - } - sw.mu.Unlock() - // begin a database transaction to update the wallet state err = sw.store.UpdateWallet(cc.ID, uint64(cc.BlockHeight), func(tx UpdateTransaction) error { // add new siacoin outputs and remove spent or reverted siacoin outputs @@ -749,12 +691,6 @@ func (sw *SingleAddressWallet) ProcessConsensusChange(cc modules.ConsensusChange sw.log.Panic("failed to update wallet", zap.Error(err), zap.String("changeID", cc.ID.String()), zap.Uint64("height", uint64(cc.BlockHeight))) } - sw.mu.Lock() - for _, id := range locked { - delete(sw.consensusLocked, id) - } - sw.mu.Unlock() - atomic.StoreUint64(&sw.scanHeight, uint64(cc.BlockHeight)) sw.log.Debug("applied consensus change", zap.String("changeID", cc.ID.String()), zap.Int("applied", len(cc.AppliedBlocks)), zap.Int("reverted", len(cc.RevertedBlocks)), zap.Uint64("height", uint64(cc.BlockHeight)), zap.Duration("elapsed", time.Since(start)), zap.String("address", sw.addr.String())) } @@ -786,7 +722,7 @@ func convertToCore(siad encoding.SiaMarshaler, core types.DecoderFrom) { } // NewSingleAddressWallet returns a new SingleAddressWallet using the provided private key and store. -func NewSingleAddressWallet(priv types.PrivateKey, cm ChainManager, tp TransactionPool, store SingleAddressStore, log *zap.Logger) (*SingleAddressWallet, error) { +func NewSingleAddressWallet(priv types.PrivateKey, cm ChainManager, store SingleAddressStore, log *zap.Logger) (*SingleAddressWallet, error) { changeID, scanHeight, err := store.LastWalletChange() if err != nil { return nil, fmt.Errorf("failed to get last wallet change: %w", err) @@ -814,15 +750,39 @@ func NewSingleAddressWallet(priv types.PrivateKey, cm ChainManager, tp Transacti tg: threadgroup.New(), addr: types.StandardUnlockHash(priv.PublicKey()), - - locked: make(map[types.SiacoinOutputID]bool), - consensusLocked: make(map[types.SiacoinOutputID]bool), - tpoolSpent: make(map[types.SiacoinOutputID]bool), - - tpoolUtxos: make(map[types.SiacoinOutputID]SiacoinElement), - tpoolTxns: make(map[modules.TransactionSetID][]Transaction), + // locked is a set of siacoin output IDs locked by FundTransaction. They + // will be released either by calling Release for unused transactions or + // being confirmed in a block. + locked: make(map[types.SiacoinOutputID]time.Time), } + go func() { + ctx, cancel, err := sw.tg.AddContext(context.Background()) + if err != nil { + sw.log.Error("failed to add context", zap.Error(err)) + return + } + defer cancel() + + t := time.NewTicker(3 * time.Hour) + defer t.Stop() + + for { + select { + case <-t.C: + sw.mu.Lock() + for id, expiration := range sw.locked { + if expiration.Before(time.Now()) { + delete(sw.locked, id) + } + } + sw.mu.Unlock() + case <-ctx.Done(): + return + } + } + }() + go func() { // note: start in goroutine to avoid blocking startup err := cm.Subscribe(sw, changeID, sw.tg.Done()) @@ -838,6 +798,5 @@ func NewSingleAddressWallet(priv types.PrivateKey, cm ChainManager, tp Transacti sw.log.Fatal("failed to subscribe to consensus set", zap.Error(err)) } }() - tp.Subscribe(sw) return sw, nil } diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index fa2cea73..e170db0a 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -98,7 +98,6 @@ func TestWallet(t *testing.T) { t.Fatal(err) } - time.Sleep(250 * time.Millisecond) // sleep for tpool sync // check that the wallet's spendable balance and unconfirmed balance are // correct spendable, balance, unconfirmed, err := w.Balance() @@ -378,7 +377,6 @@ func TestWalletUTXOSelection(t *testing.T) { if err != nil { t.Fatal(err) } - defer release() if len(txn.SiacoinInputs) != 11 { t.Fatalf("expected 10 additional defrag inputs, got %v", len(toSign)-1) @@ -400,8 +398,10 @@ func TestWalletUTXOSelection(t *testing.T) { } if err := w.SignTransaction(w.TipState(), &txn, toSign, types.CoveredFields{WholeTransaction: true}); err != nil { + release() t.Fatal(err) } else if err := w.TPool().AcceptTransactionSet([]types.Transaction{txn}); err != nil { + release() t.Fatal(err) } }