From aee109893d82660fb6fb8ecab84e2e46f8237d3c Mon Sep 17 00:00:00 2001 From: Nate Maninger Date: Thu, 12 Sep 2024 20:17:49 -0700 Subject: [PATCH] rhp4: implement rhp4 server and basic client --- chain/chain_test.go | 12 +- chain/hostannouncement.go | 57 +- chain/hostannouncement_test.go | 30 +- chain/manager.go | 2 +- go.mod | 8 +- go.sum | 4 +- rhp/v4/host/host.go | 262 ++++++ rhp/v4/host/options.go | 32 + rhp/v4/host/rpc.go | 899 +++++++++++++++++++ rhp/v4/rpc.go | 529 +++++++++++ rhp/v4/rpc_test.go | 1511 ++++++++++++++++++++++++++++++++ testutil/host.go | 395 +++++++++ testutil/testutil.go | 8 +- 13 files changed, 3717 insertions(+), 32 deletions(-) create mode 100644 rhp/v4/host/host.go create mode 100644 rhp/v4/host/options.go create mode 100644 rhp/v4/host/rpc.go create mode 100644 rhp/v4/rpc.go create mode 100644 rhp/v4/rpc_test.go create mode 100644 testutil/host.go diff --git a/chain/chain_test.go b/chain/chain_test.go index 03df5ba..780c23d 100644 --- a/chain/chain_test.go +++ b/chain/chain_test.go @@ -183,8 +183,8 @@ func TestV2Attestations(t *testing.T) { ms.Sync(t, cm) sk := types.GeneratePrivateKey() - ann := chain.HostAnnouncement{ - NetAddress: "foo.bar:1234", + ann := chain.V2HostAnnouncement{ + chain.NetAddress{Address: "foo.bar:1234", Protocol: "tcp"}, } se := ms.SpendableElement(t) txn := types.V2Transaction{ @@ -232,8 +232,8 @@ func TestV2Attestations(t *testing.T) { ms.Sync(t, cm) sk := types.GeneratePrivateKey() - ann := chain.HostAnnouncement{ - NetAddress: "foo.bar:1234", + ann := chain.V2HostAnnouncement{ + chain.NetAddress{Address: "foo.bar:1234", Protocol: "tcp"}, } txn := types.V2Transaction{ ArbitraryData: frand.Bytes(16), @@ -276,8 +276,8 @@ func TestV2Attestations(t *testing.T) { ms.Sync(t, cm) sk := types.GeneratePrivateKey() - ann := chain.HostAnnouncement{ - NetAddress: "foo.bar:1234", + ann := chain.V2HostAnnouncement{ + chain.NetAddress{Address: "foo.bar:1234", Protocol: "tcp"}, } se := ms.SpendableElement(t) minerFee := types.Siacoins(1) diff --git a/chain/hostannouncement.go b/chain/hostannouncement.go index dea02b6..ad98605 100644 --- a/chain/hostannouncement.go +++ b/chain/hostannouncement.go @@ -2,6 +2,7 @@ package chain import ( "bytes" + "errors" "go.sia.tech/core/consensus" "go.sia.tech/core/types" @@ -12,29 +13,57 @@ const attestationHostAnnouncement = "HostAnnouncement" var specifierHostAnnouncement = types.NewSpecifier("HostAnnouncement") // A HostAnnouncement represents a signed announcement of a host's network -// address. Announcements may be made via arbitrary data (in a v1 transaction) -// or via attestation (in a v2 transaction). -type HostAnnouncement struct { - NetAddress string +// address. Announcements may be made via arbitrary data +type ( + HostAnnouncement struct { + NetAddress string + } + + // A NetAddress is a pair of protocol and address that a host may be reached on + NetAddress struct { + Protocol string `json:"protocol"` + Address string `json:"address"` + } + + // A V2HostAnnouncement lists all the network addresses a host may be reached on + V2HostAnnouncement []NetAddress +) + +// EncodeTo implements types.EncoderTo. +func (na NetAddress) EncodeTo(e *types.Encoder) { + e.WriteString(na.Protocol) + e.WriteString(na.Address) +} + +// DecodeFrom implements types.DecoderFrom. +func (na *NetAddress) DecodeFrom(d *types.Decoder) { + na.Protocol = d.ReadString() + na.Address = d.ReadString() } // ToAttestation encodes a host announcement as an attestation. -func (ha HostAnnouncement) ToAttestation(cs consensus.State, sk types.PrivateKey) types.Attestation { +func (ha V2HostAnnouncement) ToAttestation(cs consensus.State, sk types.PrivateKey) types.Attestation { + buf := bytes.NewBuffer(nil) + e := types.NewEncoder(buf) + types.EncodeSlice(e, ha) + e.Flush() a := types.Attestation{ PublicKey: sk.PublicKey(), Key: attestationHostAnnouncement, - Value: []byte(ha.NetAddress), + Value: buf.Bytes(), } a.Signature = sk.SignHash(cs.AttestationSigHash(a)) return a } -func (ha *HostAnnouncement) fromAttestation(a types.Attestation) bool { +// FromAttestation decodes a host announcement from an attestation. +func (ha *V2HostAnnouncement) FromAttestation(a types.Attestation) error { if a.Key != attestationHostAnnouncement { - return false + return errors.New("not a host announcement") } - ha.NetAddress = string(a.Value) - return true + d := types.NewBufDecoder(a.Value) + types.DecodeSlice(d, (*[]NetAddress)(ha)) + return d.Err() } // ToArbitraryData encodes a host announcement as arbitrary data. @@ -81,10 +110,14 @@ func ForEachHostAnnouncement(b types.Block, fn func(types.PublicKey, HostAnnounc } } } +} + +// ForEachV2HostAnnouncement calls fn on each v2 host announcement in a block. +func ForEachV2HostAnnouncement(b types.Block, fn func(types.PublicKey, []NetAddress)) { for _, txn := range b.V2Transactions() { for _, a := range txn.Attestations { - var ha HostAnnouncement - if ha.fromAttestation(a) { + var ha V2HostAnnouncement + if err := ha.FromAttestation(a); err == nil { fn(a.PublicKey, ha) } } diff --git a/chain/hostannouncement_test.go b/chain/hostannouncement_test.go index a049992..dbb921e 100644 --- a/chain/hostannouncement_test.go +++ b/chain/hostannouncement_test.go @@ -16,17 +16,41 @@ func TestForEachHostAnnouncement(t *testing.T) { Transactions: []types.Transaction{ {ArbitraryData: [][]byte{ha.ToArbitraryData(sk)}}, }, + } + ForEachHostAnnouncement(b, func(pk types.PublicKey, a HostAnnouncement) { + if pk != sk.PublicKey() { + t.Error("pubkey mismatch") + } else if a.NetAddress != ha.NetAddress { + t.Error("address mismatch:", a, ha) + } + }) +} + +func TestForEachV2HostAnnouncement(t *testing.T) { + sk := types.GeneratePrivateKey() + ha := V2HostAnnouncement([]NetAddress{ + {Protocol: "tcp", Address: "foo.bar:1234"}, + {Protocol: "tcp6", Address: "baz.qux:5678"}, + {Protocol: "webtransport", Address: "quux.corge:91011"}, + }) + b := types.Block{ V2: &types.V2BlockData{ Transactions: []types.V2Transaction{ {Attestations: []types.Attestation{ha.ToAttestation(consensus.State{}, sk)}}, }, }, } - ForEachHostAnnouncement(b, func(pk types.PublicKey, a HostAnnouncement) { + ForEachV2HostAnnouncement(b, func(pk types.PublicKey, addresses []NetAddress) { if pk != sk.PublicKey() { t.Error("pubkey mismatch") - } else if a.NetAddress != ha.NetAddress { - t.Error("address mismatch:", a, ha) + } else if len(addresses) != len(ha) { + t.Error("length mismatch") + } else { + for i := range addresses { + if addresses[i] != ha[i] { + t.Error("address mismatch:", addresses[i], ha[i]) + } + } } }) } diff --git a/chain/manager.go b/chain/manager.go index 73db76c..32efce9 100644 --- a/chain/manager.go +++ b/chain/manager.go @@ -941,7 +941,7 @@ func (m *Manager) UnconfirmedParents(txn types.Transaction) []types.Transaction // V2TransactionSet returns the full transaction set and basis necessary for // broadcasting a transaction. If the provided basis does not match the current -// tip the transaction will be updated. The transaction set includes the parents +// tip, the transaction will be updated. The transaction set includes the parents // and the transaction itself in an order valid for broadcasting. func (m *Manager) V2TransactionSet(basis types.ChainIndex, txn types.V2Transaction) (types.ChainIndex, []types.V2Transaction, error) { m.mu.Lock() diff --git a/go.mod b/go.mod index 399ff81..373aa8f 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,11 @@ module go.sia.tech/coreutils -go 1.22 - -toolchain go1.23.0 +go 1.23.0 require ( go.etcd.io/bbolt v1.3.11 - go.sia.tech/core v0.4.6 + go.sia.tech/core v0.4.7-0.20240913031448-c46d83451426 + go.sia.tech/mux v1.2.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.27.0 lukechampine.com/frand v1.4.2 @@ -14,7 +13,6 @@ require ( require ( github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect - go.sia.tech/mux v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sys v0.25.0 // indirect ) diff --git a/go.sum b/go.sum index a2c13b1..6a142d2 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= -go.sia.tech/core v0.4.6 h1:QLm97a7GWBonfnMEOokqWRAqsWCUPL7kzo6k3Adwx8E= -go.sia.tech/core v0.4.6/go.mod h1:Zuq0Tn2aIXJyO0bjGu8cMeVWe+vwQnUfZhG1LCmjD5c= +go.sia.tech/core v0.4.7-0.20240913031448-c46d83451426 h1:FW7aPkAlee5gEmIHwQYKD7fTylmaEm+4V9SyHNkdzGE= +go.sia.tech/core v0.4.7-0.20240913031448-c46d83451426/go.mod h1:S7IRFqUy/vo2+oPtDq/o4rlAegNyoYQ7TWqsulE9R+w= go.sia.tech/mux v1.2.0 h1:ofa1Us9mdymBbGMY2XH/lSpY8itFsKIo/Aq8zwe+GHU= go.sia.tech/mux v1.2.0/go.mod h1:Yyo6wZelOYTyvrHmJZ6aQfRoer3o4xyKQ4NmQLJrBSo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/rhp/v4/host/host.go b/rhp/v4/host/host.go new file mode 100644 index 0000000..da0165c --- /dev/null +++ b/rhp/v4/host/host.go @@ -0,0 +1,262 @@ +package host + +import ( + "encoding/hex" + "errors" + "fmt" + "net" + "time" + + "go.sia.tech/core/consensus" + rhp4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" + "go.uber.org/zap" + "lukechampine.com/frand" +) + +var protocolVersion = [3]byte{0, 0, 1} + +type ( + // Usage contains the revenue and risked collateral for a contract. + Usage struct { + RPCRevenue types.Currency `json:"rpc"` + StorageRevenue types.Currency `json:"storage"` + EgressRevenue types.Currency `json:"egress"` + IngressRevenue types.Currency `json:"ingress"` + AccountFunding types.Currency `json:"accountFunding"` + RiskedCollateral types.Currency `json:"riskedCollateral"` + } + + // A TransactionSet contains the transaction set and basis for a v2 contract. + TransactionSet struct { + TransactionSet []types.V2Transaction + Basis types.ChainIndex + } +) + +type ( + // A Transport is a generic multiplexer for incoming streams. + Transport interface { + AcceptStream() (net.Conn, error) + Close() error + } + + // ChainManager defines the interface required by the contract manager to + // interact with the consensus set. + ChainManager interface { + Tip() types.ChainIndex + TipState() consensus.State + + // V2TransactionSet returns the full transaction set and basis necessary for + // broadcasting a transaction. If the provided basis does not match the current + // tip, the transaction will be updated. The transaction set includes the parents + // and the transaction itself in an order valid for broadcasting. + V2TransactionSet(basis types.ChainIndex, txn types.V2Transaction) (types.ChainIndex, []types.V2Transaction, error) + // AddV2PoolTransactions validates a transaction set and adds it to the + // transaction pool. + AddV2PoolTransactions(types.ChainIndex, []types.V2Transaction) (known bool, err error) + // RecommendedFee returns the recommended fee per weight + RecommendedFee() types.Currency + // UpdatesSince returns at most max updates on the path between index and the + // Manager's current tip. + UpdatesSince(index types.ChainIndex, maxBlocks int) (rus []chain.RevertUpdate, aus []chain.ApplyUpdate, err error) + } + + // A Syncer broadcasts transactions to its peers + Syncer interface { + // BroadcastV2TransactionSet broadcasts a transaction set to the network. + BroadcastV2TransactionSet(types.ChainIndex, []types.V2Transaction) + } + + // A Wallet manages Siacoins and funds transactions + Wallet interface { + // Address returns the host's address + Address() types.Address + + // FundTransaction funds a transaction with the specified amount of + // Siacoins. If useUnconfirmed is true, the transaction may spend + // unconfirmed outputs. The outputs spent by the transaction are locked + // until they are released by ReleaseInputs. + FundV2Transaction(txn *types.V2Transaction, amount types.Currency, useUnconfirmed bool) (types.ChainIndex, []int, error) + // SignV2Inputs signs the inputs of a transaction. + SignV2Inputs(txn *types.V2Transaction, toSign []int) + // ReleaseInputs releases the inputs of a transaction. It should only + // be used if the transaction is not going to be broadcast + ReleaseInputs(txns []types.Transaction, v2txns []types.V2Transaction) + } + + // A SectorStore is an interface for reading and writing sectors + SectorStore interface { + ReadSector(types.Hash256) ([rhp4.SectorSize]byte, error) + // StoreSector stores a sector and returns its root hash. + StoreSector(root types.Hash256, data *[rhp4.SectorSize]byte, expiration uint64) error + } + + // A RevisionState pairs a contract revision with its sector roots + RevisionState struct { + Revision types.V2FileContract + Roots []types.Hash256 + } + + // Contractor is an interface for managing a host's contracts + Contractor interface { + // LockV2Contract locks a contract and returns its current state. + // The returned function must be called to release the lock. + LockV2Contract(types.FileContractID) (RevisionState, func(), error) + // AddV2Contract adds a new contract to the host + AddV2Contract(TransactionSet, Usage) error + // RenewV2Contract finalizes an existing contract and adds its renewal + RenewV2Contract(TransactionSet, Usage) error + // ReviseV2Contract atomically revises a contract and updates its sector + // roots and usage + ReviseV2Contract(contractID types.FileContractID, revision types.V2FileContract, roots []types.Hash256, usage Usage) error + // ContractElement returns the contract state element for the given + // contract ID + ContractElement(types.FileContractID) (types.ChainIndex, types.V2FileContractElement, error) + + AccountBalance(rhp4.Account) (types.Currency, error) + CreditAccountsWithContract([]rhp4.AccountDeposit, types.FileContractID, types.V2FileContract) ([]types.Currency, error) + DebitAccount(rhp4.Account, types.Currency) error + } + + // SettingsReporter reports the host's current settings + SettingsReporter interface { + RHP4Settings() rhp4.HostSettings + } + + // A Server handles incoming RHP4 RPC + Server struct { + hostKey types.PrivateKey + priceTableValidity time.Duration + contractProofWindowBuffer uint64 + + log *zap.Logger + + chain ChainManager + syncer Syncer + wallet Wallet + sectors SectorStore + contractor Contractor + settings SettingsReporter + } +) + +func (s *Server) lockContractForRevision(contractID types.FileContractID) (RevisionState, func(), error) { + rev, unlock, err := s.contractor.LockV2Contract(contractID) + switch { + case err != nil: + return RevisionState{}, nil, err + case rev.Revision.ProofHeight-s.contractProofWindowBuffer <= s.chain.Tip().Height: + unlock() + return RevisionState{}, nil, errorBadRequest("contract too close to proof window") + case rev.Revision.RevisionNumber >= types.MaxRevisionNumber: + unlock() + return RevisionState{}, nil, errorBadRequest("contract is locked for revision") + } + return rev, unlock, nil +} + +func (s *Server) handleHostStream(stream net.Conn, log *zap.Logger) { + defer stream.Close() + + stream.SetDeadline(time.Now().Add(30 * time.Second)) // set an initial timeout + rpcStart := time.Now() + id, err := rhp4.ReadID(stream) + if err != nil { + log.Debug("failed to read RPC ID", zap.Error(err)) + return + } + + switch id { + case rhp4.RPCSettingsID: + err = s.handleRPCSettings(stream) + case rhp4.RPCAccountBalanceID: + err = s.handleRPCAccountBalance(stream) + case rhp4.RPCFormContractID: + err = s.handleRPCFormContract(stream) + case rhp4.RPCFundAccountsID: + err = s.handleRPCFundAccounts(stream) + case rhp4.RPCLatestRevisionID: + err = s.handleRPCLatestRevision(stream) + case rhp4.RPCModifySectorsID: + err = s.handleRPCModifySectors(stream) + case rhp4.RPCReadSectorID: + err = s.handleRPCReadSector(stream) + case rhp4.RPCRenewContractID: + err = s.handleRPCRenewContract(stream) + case rhp4.RPCSectorRootsID: + err = s.handleRPCSectorRoots(stream) + case rhp4.RPCWriteSectorID: + err = s.handleRPCWriteSector(stream) + default: + log.Debug("unrecognized RPC", zap.Stringer("rpc", id)) + rhp4.WriteResponse(stream, &rhp4.RPCError{Code: rhp4.ErrorCodeBadRequest, Description: "unrecognized RPC"}) + return + } + if err != nil { + var re *rhp4.RPCError + if ok := errors.As(err, &re); ok { + rhp4.WriteResponse(stream, re) + log.Debug("RPC failed", zap.Error(err), zap.Duration("elapsed", time.Since(rpcStart))) + } else { + rhp4.WriteResponse(stream, rhp4.ErrHostInternalError.(*rhp4.RPCError)) + log.Error("RPC failed", zap.Error(err), zap.Duration("elapsed", time.Since(rpcStart))) + } + return + } + log.Info("RPC success", zap.Duration("elapsed", time.Since(rpcStart))) +} + +// HostKey returns the host's private key +func (s *Server) HostKey() types.PrivateKey { + return s.hostKey +} + +// Serve accepts incoming streams on the provided multiplexer and handles them +func (s *Server) Serve(t Transport, log *zap.Logger) error { + defer t.Close() + + for { + stream, err := t.AcceptStream() + if errors.Is(err, net.ErrClosed) { + return nil + } else if err != nil { + return fmt.Errorf("failed to accept connection: %w", err) + } + log := log.With(zap.String("streamID", hex.EncodeToString(frand.Bytes(4)))) + log.Debug("accepted stream") + go func() { + defer func() { + if err := stream.Close(); err != nil { + log.Debug("failed to close stream", zap.Error(err)) + } else { + log.Debug("closed stream") + } + }() + s.handleHostStream(stream, log) + }() + } +} + +// NewServer creates a new RHP4 server +func NewServer(pk types.PrivateKey, cm ChainManager, syncer Syncer, contracts Contractor, wallet Wallet, settings SettingsReporter, sectors SectorStore, opts ...ServerOption) *Server { + s := &Server{ + hostKey: pk, + priceTableValidity: 30 * time.Minute, + contractProofWindowBuffer: 10, + + log: zap.NewNop(), + + chain: cm, + syncer: syncer, + wallet: wallet, + sectors: sectors, + contractor: contracts, + settings: settings, + } + for _, opt := range opts { + opt(s) + } + return s +} diff --git a/rhp/v4/host/options.go b/rhp/v4/host/options.go new file mode 100644 index 0000000..afc011b --- /dev/null +++ b/rhp/v4/host/options.go @@ -0,0 +1,32 @@ +package host + +import ( + "time" + + "go.uber.org/zap" +) + +// A ServerOption sets an option on a Server. +type ServerOption func(*Server) + +// WithLog sets the logger for the server. +func WithLog(log *zap.Logger) ServerOption { + return func(s *Server) { + s.log = log + } +} + +// WithPriceTableValidity sets the duration for which a price table is valid. +func WithPriceTableValidity(validity time.Duration) ServerOption { + return func(s *Server) { + s.priceTableValidity = validity + } +} + +// WithContractProofWindowBuffer sets the buffer for revising a contract before +// its proof window starts. +func WithContractProofWindowBuffer(buffer uint64) ServerOption { + return func(s *Server) { + s.contractProofWindowBuffer = buffer + } +} diff --git a/rhp/v4/host/rpc.go b/rhp/v4/host/rpc.go new file mode 100644 index 0000000..6fc1e99 --- /dev/null +++ b/rhp/v4/host/rpc.go @@ -0,0 +1,899 @@ +package host + +import ( + "errors" + "fmt" + "io" + "net" + "time" + + rhp4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + "go.sia.tech/coreutils/wallet" + "go.uber.org/zap" +) + +const maxBasisDiff = 18 + +func errorBadRequest(f string, p ...any) error { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, fmt.Sprintf(f, p...)) +} + +func errorDecodingError(f string, p ...any) error { + return rhp4.NewRPCError(rhp4.ErrorCodeDecoding, fmt.Sprintf(f, p...)) +} + +func (s *Server) handleRPCSettings(stream net.Conn) error { + settings := s.settings.RHP4Settings() + settings.ProtocolVersion = protocolVersion + settings.Prices.ValidUntil = time.Now().Add(s.priceTableValidity) + sigHash := settings.Prices.SigHash() + settings.Prices.Signature = s.hostKey.SignHash(sigHash) + + return rhp4.WriteResponse(stream, &rhp4.RPCSettingsResponse{ + Settings: settings, + }) +} + +func (s *Server) handleRPCReadSector(stream net.Conn) error { + var req rhp4.RPCReadSectorRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + prices, token := req.Prices, req.Token + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return fmt.Errorf("price table invalid: %w", err) + } else if err := token.Validate(); err != nil { + return fmt.Errorf("token invalid: %w", err) + } + + switch { + case req.Length%rhp4.LeafSize != 0: + return errorBadRequest("requested length must be a multiple of segment size %v", rhp4.LeafSize) + case req.Offset+req.Length > rhp4.SectorSize: + return errorBadRequest("requested offset %v and length %v exceed sector size %v", req.Offset, req.Length, rhp4.SectorSize) + } + + if err := s.contractor.DebitAccount(req.Token.Account, prices.RPCReadSectorCost(req.Length)); err != nil { + return fmt.Errorf("failed to debit account: %w", err) + } + + sector, err := s.sectors.ReadSector(req.Root) + if err != nil { + return fmt.Errorf("failed to read sector: %w", err) + } + + segment := sector[req.Offset : req.Offset+req.Length] + + return rhp4.WriteResponse(stream, &rhp4.RPCReadSectorResponse{ + Sector: segment, + Proof: nil, // TODO implement proof + }) +} + +func (s *Server) handleRPCWriteSector(stream net.Conn) error { + elapsed := func(context string) func() { + start := time.Now() + return func() { + s.log.Debug("RPCWriteSector", zap.String("context", context), zap.Duration("duration", time.Since(start))) + } + } + log := elapsed("write sector streaming") + var req rhp4.RPCWriteSectorStreamingRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + log() + + log = elapsed("validate prices") + prices, token := req.Prices, req.Token + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return fmt.Errorf("price table invalid: %w", err) + } + log() + + log = elapsed("validate token") + if err := token.Validate(); err != nil { + return fmt.Errorf("token invalid: %w", err) + } + log() + + settings := s.settings.RHP4Settings() + + log = elapsed("validate request") + switch { + case req.DataLength > rhp4.SectorSize: + return errorBadRequest("sector size %v exceeds maximum %v", req.DataLength, rhp4.SectorSize) + case req.DataLength%rhp4.LeafSize != 0: + return errorBadRequest("sector length %v must be a multiple of segment size %v", req.DataLength, rhp4.LeafSize) + case req.Duration > settings.MaxSectorDuration: + return errorBadRequest("sector duration %v exceeds maximum %v", req.Duration, settings.MaxSectorDuration) + } + log() + + log = elapsed("debit account") + if err := s.contractor.DebitAccount(req.Token.Account, prices.RPCWriteSectorCost(uint64(req.DataLength), req.Duration)); err != nil { + return fmt.Errorf("failed to debit account: %w", err) + } + log() + + log = elapsed("read sector") + var sector [rhp4.SectorSize]byte + if _, err := io.ReadFull(stream, sector[:req.DataLength]); err != nil { + return errorDecodingError("failed to read sector data: %v", err) + } + log() + + log = elapsed("calculate root") + root := rhp4.SectorRoot(§or) + log() + + log = elapsed("store sector") + if err := s.sectors.StoreSector(root, §or, req.Duration); err != nil { + return fmt.Errorf("failed to store sector: %w", err) + } + log() + return rhp4.WriteResponse(stream, &rhp4.RPCWriteSectorResponse{ + Root: root, + }) +} + +func (s *Server) handleRPCModifySectors(stream net.Conn) error { + var req rhp4.RPCModifySectorsRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + prices := req.Prices + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return fmt.Errorf("price table invalid: %w", err) + } + settings := s.settings.RHP4Settings() + + if err := rhp4.ValidateModifyActions(req.Actions, settings.MaxModifyActions); err != nil { + return fmt.Errorf("modify actions invalid: %w", err) + } + + cs := s.chain.TipState() + + state, unlock, err := s.lockContractForRevision(req.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract: %w", err) + } + defer unlock() + + if !req.ValidChallengeSignature(state.Revision) { + return errorBadRequest("invalid challenge signature") + } + + fc := state.Revision + duration := fc.ExpirationHeight - prices.TipHeight + cost, collateral := prices.RPCModifySectorsCost(req.Actions, duration) + + // validate the payment without modifying the contract + if fc.RenterOutput.Value.Cmp(cost) < 0 { + return rhp4.NewRPCError(rhp4.ErrorCodePayment, fmt.Sprintf("renter output value %v is less than cost %v", fc.RenterOutput.Value, cost)) + } else if fc.MissedHostValue.Cmp(collateral) < 0 { + return rhp4.NewRPCError(rhp4.ErrorCodePayment, fmt.Sprintf("missed host value %v is less than collateral %v", fc.MissedHostValue, collateral)) + } + + roots := state.Roots + for _, action := range req.Actions { + switch action.Type { + case rhp4.ActionAppend: + roots = append(roots, action.Root) + case rhp4.ActionTrim: + if action.N > uint64(len(roots)) { + return errorBadRequest("trim count %v exceeds sector count %v", action.N, len(roots)) + } + roots = roots[:len(roots)-int(action.N)] + case rhp4.ActionUpdate: + if action.A >= uint64(len(roots)) { + return errorBadRequest("update index %v exceeds sector count %v", action.A, len(roots)) + } + roots[action.A] = action.Root + case rhp4.ActionSwap: + if action.A >= uint64(len(roots)) || action.B >= uint64(len(roots)) { + return errorBadRequest("swap indices %v and %v exceed sector count %v", action.A, action.B, len(roots)) + } + roots[action.A], roots[action.B] = roots[action.B], roots[action.A] + default: + return errorBadRequest("unknown action type %v", action.Type) + } + } + + resp := rhp4.RPCModifySectorsResponse{ + Proof: []types.Hash256{rhp4.MetaRoot(roots)}, // TODO implement proof + } + if err := rhp4.WriteResponse(stream, &resp); err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + + var renterSigResponse rhp4.RPCModifySectorsSecondResponse + if err := rhp4.ReadResponse(stream, &renterSigResponse); err != nil { + return errorDecodingError("failed to read renter signature response: %v", err) + } + + // revise contract + revision, err := rhp4.ReviseForModifySectors(fc, req, resp) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + + // validate the renter signature + if !fc.RenterPublicKey.VerifyHash(sigHash, renterSigResponse.RenterSignature) { + return rhp4.ErrInvalidSignature + } + revision.RenterSignature = renterSigResponse.RenterSignature + // sign the revision + revision.HostSignature = s.hostKey.SignHash(sigHash) + + err = s.contractor.ReviseV2Contract(req.ContractID, revision, roots, Usage{ + StorageRevenue: cost, + }) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + return rhp4.WriteResponse(stream, &rhp4.RPCModifySectorsThirdResponse{ + HostSignature: revision.HostSignature, + }) +} + +func (s *Server) handleRPCFundAccounts(stream net.Conn) error { + var req rhp4.RPCFundAccountsRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + state, unlock, err := s.lockContractForRevision(req.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract: %w", err) + } + defer unlock() + + var totalDeposits types.Currency + for _, deposit := range req.Deposits { + totalDeposits = totalDeposits.Add(deposit.Amount) + } + + fc := state.Revision + fc.RevisionNumber++ + if err := rhp4.PayWithContract(&fc, totalDeposits, types.ZeroCurrency); err != nil { + return fmt.Errorf("failed to pay with contract: %w", err) + } + + sigHash := s.chain.TipState().ContractSigHash(fc) + if !fc.RenterPublicKey.VerifyHash(sigHash, req.RenterSignature) { + return rhp4.ErrInvalidSignature + } + + fc.HostSignature = s.hostKey.SignHash(sigHash) + + balances, err := s.contractor.CreditAccountsWithContract(req.Deposits, req.ContractID, fc) + if err != nil { + return fmt.Errorf("failed to credit account: %w", err) + } + + return rhp4.WriteResponse(stream, &rhp4.RPCFundAccountsResponse{ + Balances: balances, + HostSignature: fc.HostSignature, + }) +} + +func (s *Server) handleRPCLatestRevision(stream net.Conn) error { + var req rhp4.RPCLatestRevisionRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + state, unlock, err := s.contractor.LockV2Contract(req.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract: %w", err) + } + defer unlock() + + return rhp4.WriteResponse(stream, &rhp4.RPCLatestRevisionResponse{ + Contract: state.Revision, + }) +} + +func (s *Server) handleRPCSectorRoots(stream net.Conn) error { + var req rhp4.RPCSectorRootsRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + prices := req.Prices + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return fmt.Errorf("price table invalid: %w", err) + } + + state, unlock, err := s.lockContractForRevision(req.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract: %w", err) + } + defer unlock() + + // update the revision + revision, err := rhp4.ReviseForSectorRoots(state.Revision, prices, req.Length) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + cs := s.chain.TipState() + sigHash := cs.ContractSigHash(revision) + + // validate the request + switch { + case req.Offset+req.Length > uint64(len(state.Roots)): + return errorBadRequest("requested offset %v and length %v exceed sector count %v", req.Offset, req.Length, len(state.Roots)) + case !revision.RenterPublicKey.VerifyHash(sigHash, req.RenterSignature): + return rhp4.ErrInvalidSignature + } + + // sign the revision + revision.HostSignature = s.hostKey.SignHash(sigHash) + + // update the contract + err = s.contractor.ReviseV2Contract(req.ContractID, revision, state.Roots, Usage{ + EgressRevenue: prices.RPCSectorRootsCost(req.Length), + }) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + + // send the response + return rhp4.WriteResponse(stream, &rhp4.RPCSectorRootsResponse{ + Proof: nil, // TODO: proof + Roots: state.Roots, + HostSignature: revision.HostSignature, + }) +} + +func (s *Server) handleRPCAccountBalance(stream net.Conn) error { + var req rhp4.RPCAccountBalanceRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + balance, err := s.contractor.AccountBalance(req.Account) + if err != nil { + return fmt.Errorf("failed to get account balance: %w", err) + } + + return rhp4.WriteResponse(stream, &rhp4.RPCAccountBalanceResponse{ + Balance: balance, + }) +} + +func (s *Server) handleRPCFormContract(stream net.Conn) error { + var req rhp4.RPCFormContractRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + prices := req.Prices + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return err + } + + fc := req.Contract + + // validate contract + settings := s.settings.RHP4Settings() + minHostRevenue := prices.ContractPrice.Add(fc.TotalCollateral) + switch { + case fc.Filesize != 0: + return errorBadRequest("filesize must be 0") + case fc.FileMerkleRoot != (types.Hash256{}): + return errorBadRequest("file merkle root must be empty") + case fc.ProofHeight < req.Prices.TipHeight+s.contractProofWindowBuffer: + return errorBadRequest("proof height %v is too low", fc.ProofHeight) + case fc.ExpirationHeight < fc.ProofHeight: + return errorBadRequest("expiration height %v is before proof height %v", fc.ExpirationHeight, fc.ProofHeight) + case fc.ExpirationHeight-req.Prices.TipHeight > settings.MaxContractDuration: + return errorBadRequest("duration %v exceeds maximum %v", fc.ExpirationHeight-req.Prices.TipHeight, settings.MaxContractDuration) + case fc.TotalCollateral.Cmp(settings.MaxCollateral) > 0: + return errorBadRequest("total collateral %v exceeds maximum %v", fc.TotalCollateral, settings.MaxCollateral) + case !fc.MissedHostValue.Equals(fc.TotalCollateral): + return errorBadRequest("missed host value %v must equal total collateral %v", fc.MissedHostValue, fc.HostOutput.Value) + case fc.HostOutput.Value.Cmp(minHostRevenue) < 0: + return errorBadRequest("host output value %v must be greater than or equal to expected %v", fc.HostOutput.Value, minHostRevenue) + } + + formationRevenue := fc.HostOutput.Value.Sub(fc.TotalCollateral) + formationTxn := types.V2Transaction{ + MinerFee: req.MinerFee, + FileContracts: []types.V2FileContract{fc}, + } + + // calculate the renter inputs + var renterInputs types.Currency + for _, sce := range req.RenterInputs { + formationTxn.SiacoinInputs = append(formationTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: sce, + }) + renterInputs = renterInputs.Add(sce.SiacoinOutput.Value) + } + + // calculate the required funding + cs := s.chain.TipState() + requiredRenterFunding := rhp4.ContractRenterCost(cs, prices, fc, formationTxn.MinerFee) + // validate the renter added enough inputs + if n := renterInputs.Cmp(requiredRenterFunding); n < 0 { + return errorBadRequest("renter funding %v is less than required funding %v", renterInputs, requiredRenterFunding) + } else if n > 0 { + // if the renter added too much, add a change output + formationTxn.SiacoinOutputs = append(formationTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: fc.RenterOutput.Address, + Value: renterInputs.Sub(requiredRenterFunding), + }) + } + + // validate the renter's contract signature + sigHash := cs.ContractSigHash(fc) + if !fc.RenterPublicKey.VerifyHash(sigHash, fc.RenterSignature) { + return rhp4.ErrInvalidSignature + } + + // fund the host collateral + basis, toSign, err := s.wallet.FundV2Transaction(&formationTxn, fc.TotalCollateral, true) + if errors.Is(err, wallet.ErrNotEnoughFunds) { + return rhp4.ErrHostFundError + } else if err != nil { + return fmt.Errorf("failed to fund transaction: %w", err) + } + // sign the transaction inputs + s.wallet.SignV2Inputs(&formationTxn, toSign) + + // send the host inputs to the renter + hostInputsResp := rhp4.RPCFormContractResponse{ + HostInputs: formationTxn.SiacoinInputs[len(req.RenterInputs):], + } + if err := rhp4.WriteResponse(stream, &hostInputsResp); err != nil { + return fmt.Errorf("failed to send host inputs: %w", err) + } + + // read the renter's signatures + var renterSigResp rhp4.RPCFormContractSecondResponse + if err := rhp4.ReadResponse(stream, &renterSigResp); err != nil { + return errorDecodingError("failed to read renter signatures: %v", err) + } else if len(renterSigResp.RenterSatisfiedPolicies) != len(req.RenterInputs) { + return errorBadRequest("expected %v satisfied policies, got %v", len(req.RenterInputs), len(renterSigResp.RenterSatisfiedPolicies)) + } + + for i := range formationTxn.SiacoinInputs[:len(req.RenterInputs)] { + formationTxn.SiacoinInputs[i].SatisfiedPolicy = renterSigResp.RenterSatisfiedPolicies[i] + } + + // add our signature to the contract + formationTxn.FileContracts[0].HostSignature = s.hostKey.SignHash(sigHash) + + if basis != req.Basis { + // update renter input basis to reflect our funding basis + if err := updateSiacoinElementBasis(s.chain, req.Basis, basis, formationTxn.SiacoinInputs[:len(req.RenterInputs)]); err != nil { + return errorBadRequest("failed to update renter inputs from %q to %q: %v", req.Basis, basis, err) + } + } + + // add the renter's parents to our transaction pool to ensure they are valid + // and update the proofs. + if len(req.RenterParents) > 0 { + if _, err := s.chain.AddV2PoolTransactions(req.Basis, req.RenterParents); err != nil { + return errorBadRequest("failed to add formation parents to transaction pool: %v", err) + } + } + + // get the full updated transaction set + basis, formationSet, err := s.chain.V2TransactionSet(basis, formationTxn) + if err != nil { + return fmt.Errorf("failed to get transaction set: %w", err) + } else if _, err = s.chain.AddV2PoolTransactions(basis, formationSet); err != nil { + return errorBadRequest("failed to broadcast formation transaction: %v", err) + } + s.syncer.BroadcastV2TransactionSet(basis, formationSet) + + // add the contract to the contractor + err = s.contractor.AddV2Contract(TransactionSet{ + TransactionSet: formationSet, + Basis: basis, + }, Usage{ + RPCRevenue: formationRevenue, + }) + if err != nil { + return fmt.Errorf("failed to add contract: %w", err) + } + + // send the finalized transaction set to the renter + return rhp4.WriteResponse(stream, &rhp4.RPCFormContractThirdResponse{ + Basis: basis, + TransactionSet: formationSet, + }) +} + +func (s *Server) handleRPCRenewContract(stream net.Conn) error { + var req rhp4.RPCRenewContractRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + // validate prices + prices := req.Prices + if err := prices.Validate(s.hostKey.PublicKey()); err != nil { + return fmt.Errorf("price table invalid: %w", err) + } + + // lock the contract + state, unlock, err := s.lockContractForRevision(req.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract %q: %w", req.ContractID, err) + } + defer unlock() + + // validate challenge signature + if !req.ValidChallengeSignature(state.Revision) { + return errorBadRequest("invalid challenge signature") + } + + renewal := req.Renewal + existing := state.Revision + + // validate the final revision + settings := s.settings.RHP4Settings() + switch { + case renewal.FinalRevision.RevisionNumber != types.MaxRevisionNumber: + return errorBadRequest("expected max revision number, got %v", renewal.FinalRevision.RevisionNumber) + case renewal.FinalRevision.Filesize != 0: + return errorBadRequest("expected filesize 0, got %v", renewal.FinalRevision.Filesize) + case renewal.FinalRevision.FileMerkleRoot != (types.Hash256{}): + return errorBadRequest("expected empty file merkle root, got %v", renewal.FinalRevision.FileMerkleRoot) + case renewal.FinalRevision.HostOutput.Address != existing.HostOutput.Address: + return errorBadRequest("host output address must not change") + case !renewal.FinalRevision.HostOutput.Value.Equals(existing.HostOutput.Value): + return errorBadRequest("host output value must not change") + case !renewal.FinalRevision.MissedHostValue.Equals(existing.MissedHostValue): + return errorBadRequest("missed host value must not") + case renewal.FinalRevision.RenterPublicKey != existing.RenterPublicKey: + return errorBadRequest("renter public key must not change") + case renewal.FinalRevision.HostPublicKey != existing.HostPublicKey: + return errorBadRequest("host public key must not change") + } + + // validate the new contract + switch { + case renewal.NewContract.ProofHeight < req.Prices.TipHeight+s.contractProofWindowBuffer: + return errorBadRequest("proof height %v is too low", renewal.NewContract.ProofHeight) + case renewal.NewContract.ExpirationHeight < renewal.NewContract.ProofHeight: + return errorBadRequest("expiration height %v is before proof height %v", renewal.NewContract.ExpirationHeight, renewal.NewContract.ProofHeight) + case renewal.NewContract.ExpirationHeight-req.Prices.TipHeight > settings.MaxContractDuration: + return errorBadRequest("duration %v exceeds maximum %v", renewal.NewContract.ExpirationHeight-req.Prices.TipHeight, settings.MaxContractDuration) + case renewal.NewContract.TotalCollateral.Cmp(settings.MaxCollateral) > 0: + return errorBadRequest("total collateral %v exceeds maximum %v", renewal.NewContract.TotalCollateral, settings.MaxCollateral) + case renewal.NewContract.ExpirationHeight < state.Revision.ExpirationHeight: + return errorBadRequest("expiration height %v is before current %v", renewal.NewContract.ExpirationHeight, state.Revision.ExpirationHeight) + case renewal.NewContract.MissedHostValue.Cmp(renewal.NewContract.HostOutput.Value) > 0: + return errorBadRequest("missed host value %v must be less than or equal to host output value %v", renewal.NewContract.MissedHostValue, renewal.NewContract.HostOutput.Value) + case renewal.NewContract.TotalCollateral.Cmp(renewal.NewContract.HostOutput.Value) > 0: + return errorBadRequest("total collateral %v must be less than or equal to host output value %v", renewal.NewContract.TotalCollateral, renewal.NewContract.HostOutput.Value) + case renewal.NewContract.RenterPublicKey != existing.RenterPublicKey: + return errorBadRequest("renter public key must not change") + case renewal.NewContract.HostPublicKey != existing.HostPublicKey: + return errorBadRequest("host public key must not change") + case renewal.NewContract.HostOutput.Address != existing.HostOutput.Address: + return errorBadRequest("host output address must not change") + case renewal.NewContract.Filesize != existing.Filesize: + return errorBadRequest("filesize must match existing contract") + case renewal.NewContract.FileMerkleRoot != existing.FileMerkleRoot: + return errorBadRequest("file merkle root must match existing contract") + case renewal.HostRollover.Cmp(existing.TotalCollateral) > 0: + return errorBadRequest("host rollover %v must be less than or equal to existing locked collateral %v", renewal.HostRollover, existing.TotalCollateral) + case renewal.HostRollover.Cmp(renewal.NewContract.TotalCollateral) > 0: + return errorBadRequest("host rollover %v must be less than or equal to total collateral %v", renewal.HostRollover, renewal.NewContract.TotalCollateral) + } + + // calculate the contract revenue + expectedRevenue := prices.ContractPrice + if renewal.NewContract.ExpirationHeight > existing.ExpirationHeight { + expectedRevenue = prices.StoragePrice.Mul64(existing.Filesize).Mul64(renewal.NewContract.ExpirationHeight - existing.ExpirationHeight) + } + + // validate the valid host output + minHostOutputValue := renewal.NewContract.TotalCollateral.Add(expectedRevenue) // more is fine + if renewal.NewContract.HostOutput.Value.Cmp(minHostOutputValue) < 0 { + return errorBadRequest("expected host output value at least %v, got %v", minHostOutputValue, renewal.NewContract.HostOutput.Value) + } + usage := Usage{ + RPCRevenue: prices.ContractPrice, + StorageRevenue: renewal.NewContract.HostOutput.Value.Sub(renewal.NewContract.TotalCollateral).Sub(prices.ContractPrice), + } + + // if the missed payout is less than the locked collateral, validate the + // risked collateral for this renewal. + if renewal.NewContract.MissedHostValue.Cmp(renewal.NewContract.TotalCollateral) < 0 { + duration := renewal.NewContract.ExpirationHeight - req.Prices.TipHeight + maxRiskedCollateral := prices.Collateral.Mul64(existing.Filesize).Mul64(duration) + riskedCollateral := renewal.NewContract.TotalCollateral.Sub(renewal.NewContract.MissedHostValue) + + if riskedCollateral.Cmp(maxRiskedCollateral) > 0 { + return errorBadRequest("risked collateral %v exceeds maximum %v", riskedCollateral, maxRiskedCollateral) + } + usage.RiskedCollateral = riskedCollateral + } + + // validate the renter's signature + cs := s.chain.TipState() + renewalSigHash := cs.RenewalSigHash(renewal) + if !existing.RenterPublicKey.VerifyHash(renewalSigHash, renewal.RenterSignature) { + return rhp4.ErrInvalidSignature + } + + // create the renewal transaction + renewalTxn := types.V2Transaction{ + MinerFee: req.MinerFee, + FileContractResolutions: []types.V2FileContractResolution{ + { + Resolution: &renewal, + }, + }, + } + // calculate the renter funding + requiredRenterFunding := rhp4.ContractRenterCost(cs, prices, renewal.NewContract, renewalTxn.MinerFee) + + // add the renter inputs + renterInputSum := renewal.RenterRollover + for _, si := range req.RenterInputs { + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: si, + }) + renterInputSum = renterInputSum.Add(si.SiacoinOutput.Value) + } + + // validate the renter added enough inputs + if !renterInputSum.Equals(requiredRenterFunding) { + return errorBadRequest("expected renter to fund %v, got %v", requiredRenterFunding, renterInputSum) + } + + fceBasis, fce, err := s.contractor.ContractElement(req.ContractID) + if err != nil { + return fmt.Errorf("failed to get contract element: %w", err) + } + renewalTxn.FileContractResolutions[0].Parent = fce + + basis := cs.Index // start with a decent basis and overwrite it if a setup transaction is needed + var renewalParents []types.V2Transaction + setupFundAmount := renewal.NewContract.TotalCollateral.Sub(renewal.HostRollover) + if !setupFundAmount.IsZero() { + // fund the locked collateral + setupTxn := types.V2Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: renewal.NewContract.HostOutput.Address, Value: setupFundAmount}, + }, + } + + var toSign []int + basis, toSign, err = s.wallet.FundV2Transaction(&setupTxn, setupFundAmount, true) + if errors.Is(err, wallet.ErrNotEnoughFunds) { + return rhp4.ErrHostFundError + } else if err != nil { + return fmt.Errorf("failed to fund transaction: %w", err) + } + // sign the transaction inputs + s.wallet.SignV2Inputs(&setupTxn, toSign) + + basis, renewalParents, err = s.chain.V2TransactionSet(basis, setupTxn) + if err != nil { + return fmt.Errorf("failed to get setup transaction set: %w", err) + } + + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: renewalParents[len(renewalParents)-1].EphemeralSiacoinOutput(0), + }) + s.wallet.SignV2Inputs(&renewalTxn, []int{len(renewalTxn.SiacoinInputs) - 1}) + } + // sign the renewal + renewal.HostSignature = s.hostKey.SignHash(renewalSigHash) + + // update renter input basis to reflect our funding basis + if basis != req.Basis { + if err := updateSiacoinElementBasis(s.chain, req.Basis, basis, renewalTxn.SiacoinInputs[:len(req.RenterInputs)]); err != nil { + return errorBadRequest("failed to update renter inputs from %q to %q: %v", req.Basis, basis, err) + } + } + // update the file contract element to reflect the funding basis + if fceBasis != basis { + if err := updateStateElementBasis(s.chain, fceBasis, basis, &fce.StateElement); err != nil { + return errorBadRequest("failed to update file contract basis: %v", err) + } + } + + // send the host inputs to the renter + hostInputsResp := rhp4.RPCRenewContractResponse{ + HostInputs: renewalTxn.SiacoinInputs[len(req.RenterInputs):], + } + if err := rhp4.WriteResponse(stream, &hostInputsResp); err != nil { + return fmt.Errorf("failed to send host inputs: %w", err) + } + + // read the renter's signatures + var renterSigResp rhp4.RPCRenewContractSecondResponse + if err := rhp4.ReadResponse(stream, &renterSigResp); err != nil { + return errorDecodingError("failed to read renter signatures: %v", err) + } else if len(renterSigResp.RenterSatisfiedPolicies) != len(req.RenterInputs) { + return errorBadRequest("expected %v satisfied policies, got %v", len(req.RenterInputs), len(renterSigResp.RenterSatisfiedPolicies)) + } + // apply the renter's signatures + for i := range renewalTxn.SiacoinInputs[:len(req.RenterInputs)] { + renewalTxn.SiacoinInputs[i].SatisfiedPolicy = renterSigResp.RenterSatisfiedPolicies[i] + } + + // add the renter's parents to our transaction pool to ensure they are valid + // and update the proofs. + if len(req.RenterParents) > 0 { + if _, err := s.chain.AddV2PoolTransactions(req.Basis, req.RenterParents); err != nil { + return errorBadRequest("failed to add formation parents to transaction pool: %v", err) + } + } + // add our setup parents to the transaction pool + if len(renewalParents) > 0 { + if _, err := s.chain.AddV2PoolTransactions(basis, renewalParents); err != nil { + return errorBadRequest("failed to add setup parents to transaction pool: %v", err) + } + } + + // get the full updated transaction set for the setup transaction + basis, setupSet, err := s.chain.V2TransactionSet(basis, renewalTxn) + if err != nil { + return fmt.Errorf("failed to get transaction set: %w", err) + } else if _, err = s.chain.AddV2PoolTransactions(basis, setupSet); err != nil { + return errorBadRequest("failed to broadcast setup transaction: %v", err) + } + + // get the full updated transaction set for the renewal transaction + basis, renewalSet, err := s.chain.V2TransactionSet(basis, renewalTxn) + if err != nil { + return fmt.Errorf("failed to get transaction set: %w", err) + } else if _, err = s.chain.AddV2PoolTransactions(basis, renewalSet); err != nil { + return errorBadRequest("failed to broadcast formation transaction: %v", err) + } + s.syncer.BroadcastV2TransactionSet(basis, renewalSet) + + // add the contract to the contractor + err = s.contractor.RenewV2Contract(TransactionSet{ + TransactionSet: renewalSet, + Basis: basis, + }, usage) + if err != nil { + return fmt.Errorf("failed to add contract: %w", err) + } + + // send the finalized transaction set to the renter + return rhp4.WriteResponse(stream, &rhp4.RPCRenewContractThirdResponse{ + Basis: basis, + TransactionSet: renewalSet, + }) +} + +func updateStateElementBasis(cm ChainManager, base, target types.ChainIndex, element *types.StateElement) error { + reverted, applied, err := cm.UpdatesSince(base, 144) + if err != nil { + return err + } + + if len(reverted)+len(applied) > maxBasisDiff { + return fmt.Errorf("too many updates between %v and %v", base, target) + } + + for _, cru := range reverted { + revertedIndex := types.ChainIndex{ + Height: cru.State.Index.Height + 1, + ID: cru.Block.ParentID, + } + if revertedIndex == target { + return nil + } + + cru.UpdateElementProof(element) + base = revertedIndex + } + + for _, cau := range applied { + if cau.State.Index == target { + return nil + } + + modified := make(map[types.Hash256]types.StateElement) + cau.ForEachSiacoinElement(func(sce types.SiacoinElement, created bool, spent bool) { + if created { + modified[sce.ID] = sce.StateElement + } + }) + + cau.UpdateElementProof(element) + base = cau.State.Index + } + + if base != target { + return fmt.Errorf("failed to update basis to target %v, current %v", target, base) + } + return nil +} + +// updateSiacoinElementBasis is a helper to update a transaction's siacoin elements +// to the target basis. If an error is returned, inputs must be considered invalid. +func updateSiacoinElementBasis(cm ChainManager, base, target types.ChainIndex, inputs []types.V2SiacoinInput) error { + reverted, applied, err := cm.UpdatesSince(base, 144) + if err != nil { + return err + } + + if len(reverted)+len(applied) > maxBasisDiff { + return fmt.Errorf("too many updates between %v and %v", base, target) + } + + for _, cru := range reverted { + revertedIndex := types.ChainIndex{ + Height: cru.State.Index.Height + 1, + ID: cru.Block.ParentID, + } + if revertedIndex == target { + return nil + } + + modified := make(map[types.Hash256]types.StateElement) + cru.ForEachSiacoinElement(func(sce types.SiacoinElement, created bool, spent bool) { + if created { + modified[sce.ID] = types.StateElement{ + ID: sce.ID, + LeafIndex: types.UnassignedLeafIndex, + } + } + }) + + for i := range inputs { + if se, ok := modified[inputs[i].Parent.ID]; ok { + inputs[i].Parent.StateElement = se + } + + if inputs[i].Parent.LeafIndex == types.UnassignedLeafIndex { + continue + } else if inputs[i].Parent.LeafIndex >= cru.State.Elements.NumLeaves { + return fmt.Errorf("siacoin input %v is not in the correct state", inputs[i].Parent.ID) + } + cru.UpdateElementProof(&inputs[i].Parent.StateElement) + } + base = revertedIndex + } + + for _, cau := range applied { + if cau.State.Index == target { + return nil + } + + modified := make(map[types.Hash256]types.StateElement) + cau.ForEachSiacoinElement(func(sce types.SiacoinElement, created bool, spent bool) { + if created { + modified[sce.ID] = sce.StateElement + } + }) + + for i := range inputs { + if se, ok := modified[inputs[i].Parent.ID]; ok { + inputs[i].Parent.StateElement = se + } + + if inputs[i].Parent.LeafIndex == types.UnassignedLeafIndex { + continue + } else if inputs[i].Parent.LeafIndex >= cau.State.Elements.NumLeaves { + return fmt.Errorf("siacoin input %v is not in the correct state", inputs[i].Parent.ID) + } + cau.UpdateElementProof(&inputs[i].Parent.StateElement) + } + base = cau.State.Index + } + + if base != target { + return fmt.Errorf("failed to update basis to target %v, current %v", target, base) + } + return nil +} diff --git a/rhp/v4/rpc.go b/rhp/v4/rpc.go new file mode 100644 index 0000000..a52271c --- /dev/null +++ b/rhp/v4/rpc.go @@ -0,0 +1,529 @@ +package rhp + +import ( + "errors" + "fmt" + "net" + + "go.sia.tech/core/consensus" + rhp4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" +) + +type ( + // A Transport is a generic multiplexer for incoming streams. + Transport interface { + DialStream() net.Conn + Close() error + } + + // A ChainManager reports the chain state and manages the mempool. + ChainManager interface { + Tip() types.ChainIndex + TipState() consensus.State + + // V2TransactionSet returns the full transaction set and basis necessary for + // broadcasting a transaction. If the provided basis does not match the current + // tip, the transaction will be updated. The transaction set includes the parents + // and the transaction itself in an order valid for broadcasting. + V2TransactionSet(basis types.ChainIndex, txn types.V2Transaction) (types.ChainIndex, []types.V2Transaction, error) + } + + // FundAndSign is an interface for funding and signing v2 transactions + FundAndSign interface { + FundV2Transaction(txn *types.V2Transaction, amount types.Currency) (types.ChainIndex, []int, error) + SignV2Inputs(*types.V2Transaction, []int) + } +) + +// A TransactionSet is a set of transactions is a set of transactions that +// are valid as of the provided chain index. +type TransactionSet struct { + Basis types.ChainIndex `json:"basis"` + Transactions []types.V2Transaction `json:"transactions"` +} + +// ContractRevision pairs a contract ID with a revision. +type ContractRevision struct { + ID types.FileContractID `json:"id"` + Revision types.V2FileContract `json:"revision"` +} + +// RPCSettings returns the current settings of the host +func RPCSettings(t Transport) (rhp4.HostSettings, error) { + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCSettingsID, nil); err != nil { + return rhp4.HostSettings{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCSettingsResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return rhp4.HostSettings{}, fmt.Errorf("failed to read response: %w", err) + } + return resp.Settings, nil +} + +// RPCReadSector reads a sector from the host +func RPCReadSector(t Transport, prices rhp4.HostPrices, token rhp4.AccountToken, root types.Hash256, offset, length uint64) ([]byte, error) { + if offset+length > rhp4.SectorSize { + return nil, fmt.Errorf("read exceeds sector bounds") + } + + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCReadSectorID, &rhp4.RPCReadSectorRequest{ + Prices: prices, + Token: token, + Root: root, + Offset: offset, + Length: length, + }); err != nil { + return nil, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCReadSectorResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + // TODO: verify proof + stream to writer + return resp.Sector, nil +} + +// RPCWriteSector writes a sector to the host +func RPCWriteSector(t Transport, prices rhp4.HostPrices, token rhp4.AccountToken, data []byte, duration uint64) (types.Hash256, error) { + if len(data) > rhp4.SectorSize { + return types.Hash256{}, fmt.Errorf("sector must be less than %d bytes", rhp4.SectorSize) + } else if len(data)%rhp4.LeafSize != 0 { + return types.Hash256{}, fmt.Errorf("sector must be a multiple of %d bytes", rhp4.LeafSize) + } + + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCWriteSectorID, &rhp4.RPCWriteSectorRequest{ + Prices: prices, + Token: token, + Sector: data, + Duration: duration, + }); err != nil { + return types.Hash256{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCWriteSectorResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return types.Hash256{}, fmt.Errorf("failed to read response: %w", err) + } + + // TODO: stream? + var sector [rhp4.SectorSize]byte + copy(sector[:], data) + root := rhp4.SectorRoot(§or) + if root != resp.Root { + return types.Hash256{}, fmt.Errorf("invalid root returned: expected %v, got %v", root, resp.Root) + } + return root, nil +} + +// RPCModifySectors modifies sectors on the host +func RPCModifySectors(t Transport, cs consensus.State, prices rhp4.HostPrices, sk types.PrivateKey, contract ContractRevision, actions []rhp4.WriteAction) (types.V2FileContract, error) { + s := t.DialStream() + defer s.Close() + + req := rhp4.RPCModifySectorsRequest{ + ContractID: contract.ID, + Prices: prices, + Actions: actions, + } + req.ChallengeSignature = sk.SignHash(req.ChallengeSigHash(contract.Revision.RevisionNumber + 1)) + + if err := rhp4.WriteRequest(s, rhp4.RPCModifySectorsID, &req); err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCModifySectorsResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to read response: %w", err) + } + + revision, err := rhp4.ReviseForModifySectors(contract.Revision, req, resp) + if err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to revise contract: %w", err) + } + + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = sk.SignHash(sigHash) + + signatureResp := rhp4.RPCModifySectorsSecondResponse{ + RenterSignature: revision.RenterSignature, + } + if err := rhp4.WriteResponse(s, &signatureResp); err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to write signature response: %w", err) + } + + var hostSignature rhp4.RPCModifySectorsThirdResponse + if err := rhp4.ReadResponse(s, &hostSignature); err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to read host signatures: %w", err) + } + // validate the host signature + if !contract.Revision.HostPublicKey.VerifyHash(sigHash, hostSignature.HostSignature) { + return contract.Revision, rhp4.ErrInvalidSignature + } + // return the signed revision + return revision, nil +} + +// RPCFundAccounts funds accounts on the host +func RPCFundAccounts(t Transport, cs consensus.State, sk types.PrivateKey, contract ContractRevision, deposits []rhp4.AccountDeposit) (types.V2FileContract, []types.Currency, error) { + var total types.Currency + for _, deposit := range deposits { + total = total.Add(deposit.Amount) + } + revision, err := rhp4.ReviseForFundAccount(contract.Revision, total) + if err != nil { + return types.V2FileContract{}, nil, fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = sk.SignHash(sigHash) + + req := rhp4.RPCFundAccountsRequest{ + ContractID: contract.ID, + Deposits: deposits, + RenterSignature: revision.RenterSignature, + } + + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCFundAccountsID, &req); err != nil { + return types.V2FileContract{}, nil, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCFundAccountsResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return types.V2FileContract{}, nil, fmt.Errorf("failed to read response: %w", err) + } + + // validate the host's signature + if !contract.Revision.HostPublicKey.VerifyHash(sigHash, resp.HostSignature) { + return contract.Revision, nil, rhp4.ErrInvalidSignature + } + revision.HostSignature = resp.HostSignature + return revision, resp.Balances, nil +} + +// RPCLatestRevision returns the latest revision of a contract +func RPCLatestRevision(t Transport, contractID types.FileContractID) (types.V2FileContract, error) { + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCLatestRevisionID, &rhp4.RPCLatestRevisionRequest{ContractID: contractID}); err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCLatestRevisionResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return types.V2FileContract{}, fmt.Errorf("failed to read response: %w", err) + } + return resp.Contract, nil +} + +// RPCSectorRoots returns the sector roots for a contract +func RPCSectorRoots(t Transport, cs consensus.State, prices rhp4.HostPrices, sk types.PrivateKey, contract ContractRevision, offset, length uint64) (types.V2FileContract, []types.Hash256, error) { + revision, err := rhp4.ReviseForSectorRoots(contract.Revision, prices, length) + if err != nil { + return types.V2FileContract{}, nil, fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = sk.SignHash(sigHash) + + req := rhp4.RPCSectorRootsRequest{ + Prices: prices, + ContractID: contract.ID, + Offset: offset, + Length: length, + RenterSignature: revision.RenterSignature, + } + + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCSectorRootsID, &req); err != nil { + return types.V2FileContract{}, nil, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCSectorRootsResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return types.V2FileContract{}, nil, fmt.Errorf("failed to read response: %w", err) + } + + // validate host signature + if !contract.Revision.HostPublicKey.VerifyHash(sigHash, resp.HostSignature) { + return contract.Revision, nil, rhp4.ErrInvalidSignature + } + + // TODO: validate proof + return revision, resp.Roots, nil +} + +// RPCAccountBalance returns the balance of an account +func RPCAccountBalance(t Transport, account rhp4.Account) (types.Currency, error) { + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCAccountBalanceID, &rhp4.RPCAccountBalanceRequest{Account: account}); err != nil { + return types.Currency{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCAccountBalanceResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return types.Currency{}, fmt.Errorf("failed to read response: %w", err) + } + return resp.Balance, nil +} + +// RPCFormContract forms a contract with a host +func RPCFormContract(t Transport, cm ChainManager, signer FundAndSign, prices rhp4.HostPrices, fc types.V2FileContract) (ContractRevision, TransactionSet, error) { + formationTxn := types.V2Transaction{ + MinerFee: types.Siacoins(1), + FileContracts: []types.V2FileContract{fc}, + } + + cs := cm.TipState() + renterFundAmount := rhp4.ContractRenterCost(cs, prices, fc, formationTxn.MinerFee) + basis, toSign, err := signer.FundV2Transaction(&formationTxn, renterFundAmount) + if err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to fund transaction: %w", err) + } + + basis, formationSet, err := cm.V2TransactionSet(basis, formationTxn) + if err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to get transaction set: %w", err) + } + formationTxn = formationSet[len(formationSet)-1] + + renterSiacoinElements := make([]types.SiacoinElement, 0, len(formationTxn.SiacoinInputs)) + for _, i := range formationTxn.SiacoinInputs { + renterSiacoinElements = append(renterSiacoinElements, i.Parent) + } + + req := rhp4.RPCFormContractRequest{ + Prices: prices, + Basis: basis, + MinerFee: formationTxn.MinerFee, + Contract: fc, + RenterInputs: renterSiacoinElements, + RenterParents: formationSet[:len(formationSet)-1], + } + + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCFormContractID, &req); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to write request: %w", err) + } + + var hostInputsResp rhp4.RPCFormContractResponse + if err := rhp4.ReadResponse(s, &hostInputsResp); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to read host inputs response: %w", err) + } + + // add the host inputs to the transaction + var hostInputSum types.Currency + for _, si := range hostInputsResp.HostInputs { + hostInputSum = hostInputSum.Add(si.Parent.SiacoinOutput.Value) + formationTxn.SiacoinInputs = append(formationTxn.SiacoinInputs, si) + } + + if n := hostInputSum.Cmp(fc.TotalCollateral); n < 0 { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected host to fund at least %v, got %v", fc.TotalCollateral, hostInputSum) + } else if n > 0 { + // add change output + formationTxn.SiacoinOutputs = append(formationTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: fc.HostOutput.Address, + Value: hostInputSum.Sub(fc.TotalCollateral), + }) + } + + // sign the renter inputs + signer.SignV2Inputs(&formationTxn, toSign) + + var renterPolicyResp rhp4.RPCFormContractSecondResponse + for _, si := range formationTxn.SiacoinInputs[:len(renterSiacoinElements)] { + renterPolicyResp.RenterSatisfiedPolicies = append(renterPolicyResp.RenterSatisfiedPolicies, si.SatisfiedPolicy) + } + // send the renter signatures + if err := rhp4.WriteResponse(s, &renterPolicyResp); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to write signature response: %w", err) + } + + // read the finalized transaction set + var hostTransactionSetResp rhp4.RPCFormContractThirdResponse + if err := rhp4.ReadResponse(s, &hostTransactionSetResp); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to read final response: %w", err) + } + + if len(hostTransactionSetResp.TransactionSet) == 0 { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected at least one host transaction") + } + hostFormationTxn := hostTransactionSetResp.TransactionSet[len(hostTransactionSetResp.TransactionSet)-1] + if len(hostFormationTxn.FileContracts) != 1 { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected exactly one contract") + } + formationTxnID := formationTxn.ID() + hostFormationTxnID := hostFormationTxn.ID() + if formationTxnID != hostFormationTxnID { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected transaction IDs to match %v != %v", formationTxnID, hostFormationTxnID) + } + + // validate the host signature + fc.HostSignature = hostFormationTxn.FileContracts[0].HostSignature + if !fc.HostPublicKey.VerifyHash(cs.ContractSigHash(fc), fc.HostSignature) { + return ContractRevision{}, TransactionSet{}, errors.New("invalid host signature") + } + return ContractRevision{ + ID: formationTxn.V2FileContractID(formationTxnID, 0), + Revision: fc, + }, TransactionSet{ + Basis: hostTransactionSetResp.Basis, + Transactions: hostTransactionSetResp.TransactionSet, + }, nil +} + +// RPCRenewContract renews a contract with a host +func RPCRenewContract(t Transport, cm ChainManager, signer FundAndSign, prices rhp4.HostPrices, sk types.PrivateKey, contractID types.FileContractID, existing types.V2FileContract, renewal types.V2FileContractRenewal) (ContractRevision, TransactionSet, error) { + renewalTxn := types.V2Transaction{ + MinerFee: types.Siacoins(1), + FileContractResolutions: []types.V2FileContractResolution{ + { + Parent: types.V2FileContractElement{ + StateElement: types.StateElement{ + // the other parts of the state element are not required + // for signing the transaction. Let the host fill them + // in. + ID: types.Hash256(contractID), + }, + }, + Resolution: &renewal, + }, + }, + } + + cs := cm.TipState() + setupFundAmount := rhp4.ContractRenterCost(cs, prices, renewal.NewContract, renewalTxn.MinerFee).Sub(renewal.RenterRollover) + + basis := cs.Index // start with a decent basis and overwrite it if a setup transaction is needed + var renewalParents []types.V2Transaction + if !setupFundAmount.IsZero() { + setupTxn := types.V2Transaction{ + SiacoinOutputs: []types.SiacoinOutput{ + {Address: renewal.NewContract.RenterOutput.Address, Value: setupFundAmount}, + }, + } + var err error + var toSign []int + basis, toSign, err = signer.FundV2Transaction(&setupTxn, setupFundAmount) + if err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to fund transaction: %w", err) + } + signer.SignV2Inputs(&setupTxn, toSign) + + basis, renewalParents, err = cm.V2TransactionSet(basis, setupTxn) + if err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to get transaction set: %w", err) + } + setupTxn = renewalParents[len(renewalParents)-1] + + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: setupTxn.EphemeralSiacoinOutput(0), + }) + signer.SignV2Inputs(&renewalTxn, []int{0}) + } + + renterSiacoinElements := make([]types.SiacoinElement, 0, len(renewalTxn.SiacoinInputs)) + for _, i := range renewalTxn.SiacoinInputs { + renterSiacoinElements = append(renterSiacoinElements, i.Parent) + } + + req := rhp4.RPCRenewContractRequest{ + Prices: prices, + ContractID: contractID, + Renewal: renewal, + MinerFee: renewalTxn.MinerFee, + Basis: basis, + RenterInputs: renterSiacoinElements, + RenterParents: renewalParents, + } + sigHash := req.ChallengeSigHash(existing.RevisionNumber) + req.ChallengeSignature = sk.SignHash(sigHash) + + s := t.DialStream() + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCRenewContractID, &req); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to write request: %w", err) + } + + var hostInputsResp rhp4.RPCRenewContractResponse + if err := rhp4.ReadResponse(s, &hostInputsResp); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to read host inputs response: %w", err) + } + + // add the host inputs to the transaction + hostInputSum := renewal.HostRollover + for _, si := range hostInputsResp.HostInputs { + hostInputSum = hostInputSum.Add(si.Parent.SiacoinOutput.Value) + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, si) + } + + // verify the host added enough inputs + if !hostInputSum.Equals(renewal.NewContract.TotalCollateral) { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected host to fund %v, got %v", renewal.NewContract.TotalCollateral, hostInputSum) + } + + // sign the renter inputs + signer.SignV2Inputs(&renewalTxn, []int{0}) + + // send the renter signatures + var renterPolicyResp rhp4.RPCRenewContractSecondResponse + for _, si := range renewalTxn.SiacoinInputs[:len(renterSiacoinElements)] { + renterPolicyResp.RenterSatisfiedPolicies = append(renterPolicyResp.RenterSatisfiedPolicies, si.SatisfiedPolicy) + } + if err := rhp4.WriteResponse(s, &renterPolicyResp); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to write signature response: %w", err) + } + + // read the finalized transaction set + var hostTransactionSetResp rhp4.RPCRenewContractThirdResponse + if err := rhp4.ReadResponse(s, &hostTransactionSetResp); err != nil { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("failed to read final response: %w", err) + } + + if len(hostTransactionSetResp.TransactionSet) == 0 { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected at least one host transaction") + } + hostRenewalTxn := hostTransactionSetResp.TransactionSet[len(hostTransactionSetResp.TransactionSet)-1] + if len(hostRenewalTxn.FileContractResolutions) != 1 { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected exactly one resolution") + } + + hostRenewal, ok := hostRenewalTxn.FileContractResolutions[0].Resolution.(*types.V2FileContractRenewal) + if !ok { + return ContractRevision{}, TransactionSet{}, fmt.Errorf("expected renewal resolution") + } + + // validate the host signature + renewalSigHash := cs.RenewalSigHash(renewal) + if !existing.HostPublicKey.VerifyHash(renewalSigHash, hostRenewal.HostSignature) { + return ContractRevision{}, TransactionSet{}, errors.New("invalid host signature") + } + return ContractRevision{ + ID: contractID.V2RenewalID(), + Revision: renewal.NewContract, + }, TransactionSet{ + Basis: hostTransactionSetResp.Basis, + Transactions: hostTransactionSetResp.TransactionSet, + }, nil +} diff --git a/rhp/v4/rpc_test.go b/rhp/v4/rpc_test.go new file mode 100644 index 0000000..1005d98 --- /dev/null +++ b/rhp/v4/rpc_test.go @@ -0,0 +1,1511 @@ +package rhp_test + +import ( + "bytes" + "context" + "net" + "reflect" + "sync" + "testing" + "time" + + "go.sia.tech/core/consensus" + "go.sia.tech/core/gateway" + proto4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" + rhp4 "go.sia.tech/coreutils/rhp/v4" + "go.sia.tech/coreutils/rhp/v4/host" + "go.sia.tech/coreutils/syncer" + "go.sia.tech/coreutils/testutil" + "go.sia.tech/coreutils/wallet" + "go.sia.tech/mux" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + "lukechampine.com/frand" +) + +type muxTransport struct { + m *mux.Mux +} + +func (mt *muxTransport) DialStream() net.Conn { + return mt.m.DialStream() +} +func (mt *muxTransport) Close() error { + return mt.m.Close() +} + +type fundAndSign struct { + w *wallet.SingleAddressWallet +} + +func (fs *fundAndSign) FundV2Transaction(txn *types.V2Transaction, amount types.Currency) (types.ChainIndex, []int, error) { + return fs.w.FundV2Transaction(txn, amount, true) +} +func (fs *fundAndSign) SignV2Inputs(txn *types.V2Transaction, toSign []int) { + fs.w.SignV2Inputs(txn, toSign) +} + +func testRenterHostPair(tb testing.TB, hostKey types.PrivateKey, cm host.ChainManager, s host.Syncer, w host.Wallet, c host.Contractor, sr host.SettingsReporter, ss host.SectorStore, log *zap.Logger) rhp4.Transport { + rs := host.NewServer(hostKey, cm, s, c, w, sr, ss, host.WithContractProofWindowBuffer(10), host.WithPriceTableValidity(2*time.Minute), host.WithLog(log.Named("rhp4"))) + hostAddr := testutil.ServeSiaMux(tb, rs, log.Named("siamux")) + + conn, err := net.Dial("tcp", hostAddr) + if err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { conn.Close() }) + + hostPK := hostKey.PublicKey() + m, err := mux.Dial(conn, hostPK[:]) + if err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { m.Close() }) + + return &muxTransport{m} +} + +func startTestNode(tb testing.TB, n *consensus.Network, genesis types.Block, log *zap.Logger) (*chain.Manager, *syncer.Syncer, *wallet.SingleAddressWallet) { + db, tipstate, err := chain.NewDBStore(chain.NewMemDB(), n, genesis) + if err != nil { + tb.Fatal(err) + } + cm := chain.NewManager(db, tipstate, chain.WithLog(log.Named("chain"))) + + syncerListener, err := net.Listen("tcp", ":0") + if err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { syncerListener.Close() }) + + s := syncer.New(syncerListener, cm, testutil.NewMemPeerStore(), gateway.Header{ + GenesisID: genesis.ID(), + UniqueID: gateway.GenerateUniqueID(), + NetAddress: "localhost:1234", + }, syncer.WithLogger(log.Named("syncer"))) + go s.Run(context.Background()) + tb.Cleanup(func() { s.Close() }) + + ws := testutil.NewEphemeralWalletStore() + w, err := wallet.NewSingleAddressWallet(types.GeneratePrivateKey(), cm, ws) + if err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { w.Close() }) + + reorgCh := make(chan struct{}, 1) + tb.Cleanup(func() { close(reorgCh) }) + + go func() { + for range reorgCh { + reverted, applied, err := cm.UpdatesSince(w.Tip(), 1000) + if err != nil { + tb.Error(err) + } + + err = ws.UpdateChainState(func(tx wallet.UpdateTx) error { + return w.UpdateChainState(tx, reverted, applied) + }) + if err != nil { + tb.Error(err) + } + } + }() + + stop := cm.OnReorg(func(index types.ChainIndex) { + select { + case reorgCh <- struct{}{}: + default: + } + }) + tb.Cleanup(stop) + + return cm, s, w +} + +func mineAndSync(tb testing.TB, cm *chain.Manager, addr types.Address, n int, tippers ...interface{ Tip() types.ChainIndex }) { + tb.Helper() + + testutil.MineBlocks(tb, cm, addr, n) + + for { + equals := true + for _, tipper := range tippers { + if tipper.Tip() != cm.Tip() { + equals = false + tb.Log("waiting for tip to sync") + break + } + } + if equals { + return + } + time.Sleep(time.Millisecond) + } +} + +func TestSettings(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey := types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet + mineAndSync(t, cm, w.Address(), 150) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(uint32(frand.Uint64n(10000))), + StoragePrice: types.Siacoins(uint32(frand.Uint64n(10000))), + IngressPrice: types.Siacoins(uint32(frand.Uint64n(10000))), + EgressPrice: types.Siacoins(uint32(frand.Uint64n(10000))), + Collateral: types.Siacoins(uint32(frand.Uint64n(10000))), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } else if settings.Prices.ValidUntil.Before(time.Now()) { + t.Fatal("settings expired") + } + + // verify the signature + sigHash := settings.Prices.SigHash() + if !hostKey.PublicKey().VerifyHash(sigHash, settings.Prices.Signature) { + t.Fatal("signature verification failed") + } + + // adjust the calculated fields to match the expected values + expected := sr.RHP4Settings() + expected.ProtocolVersion = settings.ProtocolVersion + expected.Prices.Signature = settings.Prices.Signature + expected.Prices.ValidUntil = settings.Prices.ValidUntil + expected.Prices.TipHeight = settings.Prices.TipHeight + + if !reflect.DeepEqual(settings, expected) { + t.Error("retrieved", settings) + t.Error("expected", expected) + t.Fatal("settings mismatch") + } +} + +func TestFormContract(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + _, finalizedSet, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(finalizedSet.Basis, finalizedSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } +} + +func TestFormContractBasis(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + _, finalizedSet, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(finalizedSet.Basis, finalizedSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } +} + +func TestRenewContractPartialRollover(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, formationSet, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(formationSet.Basis, formationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) + + // fund an account to transfer funds to the host + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + revised, _, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = revised + + // renew the contract + renewal := proto4.ReviseForRenewal(revision.Revision, settings.Prices, revision.Revision.ProofHeight+10, revision.Revision.ExpirationHeight+10, types.Siacoins(150), types.Siacoins(300)) + renewalSigHash := cs.RenewalSigHash(renewal) + renewal.RenterSignature = renterKey.SignHash(renewalSigHash) + revision, renewalSet, err := rhp4.RPCRenewContract(transport, cm, fundAndSign, settings.Prices, renterKey, revision.ID, revision.Revision, renewal) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewalSet.Basis, renewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } +} + +func TestRenewContractFullRollover(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, formationSet, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(formationSet.Basis, formationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) + + // fund an account to transfer funds to the host + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + revised, _, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = revised + + // renew the contract + renewal := proto4.ReviseForRenewal(revision.Revision, settings.Prices, revision.Revision.ProofHeight+10, revision.Revision.ExpirationHeight+10, types.Siacoins(50), types.Siacoins(100)) + renewalSigHash := cs.RenewalSigHash(renewal) + renewal.RenterSignature = renterKey.SignHash(renewalSigHash) + revision, renewalSet, err := rhp4.RPCRenewContract(transport, cm, fundAndSign, settings.Prices, renterKey, revision.ID, revision.Revision, renewal) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewalSet.Basis, renewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } +} + +func TestRenewContractNoRollover(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, formationSet, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(formationSet.Basis, formationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + + // mine a few blocks to confirm the contract + mineAndSync(t, cm, types.VoidAddress, 10, w, c) + + // fund an account to transfer funds to the host + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(100) + revised, _, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = revised + + // renew the contract + renewal := proto4.ReviseForRenewal(revision.Revision, settings.Prices, revision.Revision.ProofHeight+10, revision.Revision.ExpirationHeight+10, types.Siacoins(150), types.Siacoins(300)) + renewalSigHash := cs.RenewalSigHash(renewal) + renewal.RenterSignature = renterKey.SignHash(renewalSigHash) + revision, renewalSet, err := rhp4.RPCRenewContract(transport, cm, fundAndSign, settings.Prices, renterKey, revision.ID, revision.Revision, renewal) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewalSet.Basis, renewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } +} + +func TestAccounts(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + revised, balances, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + + renterOutputValue := revision.Revision.RenterOutput.Value.Sub(accountFundAmount) + hostOutputValue := revision.Revision.HostOutput.Value.Add(accountFundAmount) + + // verify the account was funded + if !balances[0].Equals(accountFundAmount) { + t.Fatalf("expected %v, got %v", accountFundAmount, balances[0]) + } + + // verify the contract was modified correctly + switch { + case !revised.HostOutput.Value.Equals(hostOutputValue): + t.Fatalf("expected %v, got %v", hostOutputValue, revised.HostOutput.Value) + case !revised.RenterOutput.Value.Equals(renterOutputValue): + t.Fatalf("expected %v, got %v", renterOutputValue, revised.RenterOutput.Value) + case !revised.MissedHostValue.Equals(revision.Revision.MissedHostValue): + t.Fatalf("expected %v, got %v", revision.Revision.MissedHostValue, revised.MissedHostValue) + case revised.RevisionNumber != revision.Revision.RevisionNumber+1: + t.Fatalf("expected %v, got %v", revision.Revision.RevisionNumber+1, revised.RevisionNumber) + } + + revisionSigHash := cs.ContractSigHash(revised) + if !renterKey.PublicKey().VerifyHash(revisionSigHash, revised.RenterSignature) { + t.Fatal("revision signature verification failed") + } else if !hostKey.PublicKey().VerifyHash(revisionSigHash, revised.HostSignature) { + t.Fatal("revision signature verification failed") + } + + // verify the account balance + balance, err := rhp4.RPCAccountBalance(transport, account) + if err != nil { + t.Fatal(err) + } else if !balance.Equals(accountFundAmount) { + t.Fatalf("expected %v, got %v", accountFundAmount, balance) + } +} + +func TestReadWriteSector(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + _, _, err = rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + tokenSigHash := token.SigHash() + token.Signature = renterKey.SignHash(tokenSigHash) + + data := frand.Bytes(1024) + + // store the sector + root, err := rhp4.RPCWriteSector(transport, settings.Prices, token, data, 5) + if err != nil { + t.Fatal(err) + } + + // verify the sector root + var sector [proto4.SectorSize]byte + copy(sector[:], data) + if root != proto4.SectorRoot(§or) { + t.Fatal("root mismatch") + } + + // read the sector back + buf, err := rhp4.RPCReadSector(transport, settings.Prices, token, root, 0, 64) + if err != nil { + t.Fatal(err) + } else if !bytes.Equal(buf, data[:64]) { + t.Fatal("data mismatch") + } +} + +func TestRPCModifySectors(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + revised, _, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = revised + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + tokenSigHash := token.SigHash() + token.Signature = renterKey.SignHash(tokenSigHash) + + roots := make([]types.Hash256, 50) + for i := range roots { + // store random sectors on the host + data := frand.Bytes(1024) + + // store the sector + root, err := rhp4.RPCWriteSector(transport, settings.Prices, token, data, 5) + if err != nil { + t.Fatal(err) + } + roots[i] = root + } + + assertRevision := func(t *testing.T, revision types.V2FileContract, roots []types.Hash256) { + t.Helper() + + expectedRoot := proto4.MetaRoot(roots) + n := len(roots) + + if revision.Filesize/proto4.SectorSize != uint64(n) { + t.Fatalf("expected %v sectors, got %v", n, revision.Filesize/proto4.SectorSize) + } else if revision.FileMerkleRoot != expectedRoot { + t.Fatalf("expected %v, got %v", expectedRoot, revision.FileMerkleRoot) + } + } + + // append all the sector roots to the contract + var actions []proto4.WriteAction + for _, root := range roots { + actions = append(actions, proto4.WriteAction{ + Type: proto4.ActionAppend, + Root: root, + }) + } + revised, err = rhp4.RPCModifySectors(transport, cs, settings.Prices, renterKey, revision, actions) + if err != nil { + t.Fatal(err) + } + assertRevision(t, revised, roots) + revision.Revision = revised + + // swap two random sectors + swapA, swapB := frand.Uint64n(25), frand.Uint64n(25)+25 + actions = []proto4.WriteAction{ + {Type: proto4.ActionSwap, A: swapA, B: swapB}, + } + revised, err = rhp4.RPCModifySectors(transport, cs, settings.Prices, renterKey, revision, actions) + if err != nil { + t.Fatal(err) + } + roots[swapA], roots[swapB] = roots[swapB], roots[swapA] + assertRevision(t, revised, roots) + revision.Revision = revised + + // delete the last 10 sectors + actions = []proto4.WriteAction{ + {Type: proto4.ActionTrim, N: 10}, + } + revised, err = rhp4.RPCModifySectors(transport, cs, settings.Prices, renterKey, revision, actions) + if err != nil { + t.Fatal(err) + } + trimmed, roots := roots[:len(roots)-10], roots[:40] + assertRevision(t, revised, roots) + revision.Revision = revised + + // update a random sector with one of the trimmed sectors + updateIdx := frand.Uint64n(40) + trimmedIdx := frand.Uint64n(10) + actions = []proto4.WriteAction{ + {Type: proto4.ActionUpdate, Root: trimmed[trimmedIdx], A: updateIdx}, + } + + revised, err = rhp4.RPCModifySectors(transport, cs, settings.Prices, renterKey, revision, actions) + if err != nil { + t.Fatal(err) + } + roots[updateIdx] = trimmed[trimmedIdx] + assertRevision(t, revised, roots) +} + +func TestRPCSectorRoots(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis, log) + + // fund the wallet with two UTXOs + mineAndSync(t, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(t, hostKey, cm, s, w, c, sr, ss, log) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + t.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + t.Fatal(err) + } + + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + revised, _, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = revised + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + tokenSigHash := token.SigHash() + token.Signature = renterKey.SignHash(tokenSigHash) + + roots := make([]types.Hash256, 0, 50) + + checkRoots := func(t *testing.T, expected []types.Hash256) { + t.Helper() + + revised, roots, err := rhp4.RPCSectorRoots(transport, cs, settings.Prices, renterKey, revision, 0, uint64(len(expected))) + if err != nil { + t.Fatal(err) + } else if len(roots) != len(expected) { + t.Fatalf("expected %v roots, got %v", len(expected), len(roots)) + } + for i := range roots { + if roots[i] != expected[i] { + t.Fatalf("expected %v, got %v", expected[i], roots[i]) + } + } + revision.Revision = revised + } + + for i := 0; i < cap(roots); i++ { + // store random sectors on the host + data := frand.Bytes(1024) + + // store the sector + root, err := rhp4.RPCWriteSector(transport, settings.Prices, token, data, 5) + if err != nil { + t.Fatal(err) + } + roots = append(roots, root) + + revised, err := rhp4.RPCModifySectors(transport, cs, settings.Prices, renterKey, revision, []proto4.WriteAction{ + {Type: proto4.ActionAppend, Root: root}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = revised + + checkRoots(t, roots) + } +} + +func BenchmarkWrite(b *testing.B) { + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(b, n, genesis, zap.NewNop()) + + // fund the wallet with two UTXOs + mineAndSync(b, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(b, hostKey, cm, s, w, c, sr, ss, zap.NewNop()) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + b.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + b.Fatal(err) + } + + // fund an account + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + _, _, err = rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + b.Fatal(err) + } + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + token.Signature = renterKey.SignHash(token.SigHash()) + + var sectors [][proto4.SectorSize]byte + for i := 0; i < b.N; i++ { + var sector [proto4.SectorSize]byte + frand.Read(sector[:256]) + sectors = append(sectors, sector) + } + + b.ResetTimer() + b.ReportAllocs() + b.SetBytes(proto4.SectorSize) + + for i := 0; i < b.N; i++ { + // store the sector + _, err := rhp4.RPCWriteSector(transport, settings.Prices, token, sectors[i][:], 5) + if err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkRead(b *testing.B) { + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(b, n, genesis, zap.NewNop()) + + // fund the wallet with two UTXOs + mineAndSync(b, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(b, hostKey, cm, s, w, c, sr, ss, zap.NewNop()) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + b.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + b.Fatal(err) + } + + // fund an account + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + _, _, err = rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + b.Fatal(err) + } + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + token.Signature = renterKey.SignHash(token.SigHash()) + + var sectors [][proto4.SectorSize]byte + roots := make([]types.Hash256, 0, b.N) + for i := 0; i < b.N; i++ { + var sector [proto4.SectorSize]byte + frand.Read(sector[:256]) + sectors = append(sectors, sector) + + // store the sector + root, err := rhp4.RPCWriteSector(transport, settings.Prices, token, sectors[i][:], 5) + if err != nil { + b.Fatal(err) + } + roots = append(roots, root) + } + + b.ResetTimer() + b.ReportAllocs() + b.SetBytes(proto4.SectorSize) + + for i := 0; i < b.N; i++ { + // store the sector + buf, err := rhp4.RPCReadSector(transport, settings.Prices, token, roots[i], 0, proto4.SectorSize) + if err != nil { + b.Fatal(err) + } else if !bytes.Equal(buf, sectors[i][:]) { + b.Fatal("data mismatch") + } + } +} + +func BenchmarkContractUpload(b *testing.B) { + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(b, n, genesis, zap.NewNop()) + + // fund the wallet with two UTXOs + mineAndSync(b, cm, w.Address(), 146, w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxModifyActions: 100, + RemainingStorage: 100 * proto4.SectorSize, + TotalStorage: 100 * proto4.SectorSize, + Prices: proto4.HostPrices{ + ContractPrice: types.Siacoins(1).Div64(5), // 0.2 SC + StoragePrice: types.NewCurrency64(100), // 100 H / byte / block + IngressPrice: types.NewCurrency64(100), // 100 H / byte + EgressPrice: types.NewCurrency64(100), // 100 H / byte + Collateral: types.NewCurrency64(200), + }, + }) + ss := testutil.NewEphemeralSectorStore() + c := testutil.NewEphemeralContractor(cm) + + transport := testRenterHostPair(b, hostKey, cm, s, w, c, sr, ss, zap.NewNop()) + + settings, err := rhp4.RPCSettings(transport) + if err != nil { + b.Fatal(err) + } + + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + fc := types.V2FileContract{ + ProofHeight: cm.Tip().Height + 50, + ExpirationHeight: cm.Tip().Height + 60, + RenterOutput: types.SiacoinOutput{ + Address: w.Address(), + Value: renterAllowance, + }, + HostOutput: types.SiacoinOutput{ + Address: settings.WalletAddress, + Value: hostCollateral.Add(settings.Prices.ContractPrice), + }, + TotalCollateral: hostCollateral, + MissedHostValue: hostCollateral, + RenterPublicKey: renterKey.PublicKey(), + HostPublicKey: hostKey.PublicKey(), + } + + cs := cm.TipState() + sigHash := cs.ContractSigHash(fc) + fc.RenterSignature = renterKey.SignHash(sigHash) + + fundAndSign := &fundAndSign{w} + revision, _, err := rhp4.RPCFormContract(transport, cm, fundAndSign, settings.Prices, fc) + if err != nil { + b.Fatal(err) + } + + // fund an account + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + revised, _, err := rhp4.RPCFundAccounts(transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + b.Fatal(err) + } + revision.Revision = revised + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + token.Signature = renterKey.SignHash(token.SigHash()) + + var sectors [][proto4.SectorSize]byte + for i := 0; i < b.N; i++ { + var sector [proto4.SectorSize]byte + frand.Read(sector[:256]) + sectors = append(sectors, sector) + } + + b.ResetTimer() + b.ReportAllocs() + b.SetBytes(proto4.SectorSize) + + var wg sync.WaitGroup + actions := make([]proto4.WriteAction, b.N) + for i := 0; i < b.N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + root, err := rhp4.RPCWriteSector(transport, settings.Prices, token, sectors[i][:], 5) + if err != nil { + b.Error(err) + } + actions[i] = proto4.WriteAction{ + Type: proto4.ActionAppend, + Root: root, + } + }(i) + } + + wg.Wait() + + revised, err = rhp4.RPCModifySectors(transport, cs, settings.Prices, renterKey, revision, actions) + if err != nil { + b.Fatal(err) + } else if revised.Filesize != uint64(b.N)*proto4.SectorSize { + b.Fatalf("expected %v sectors, got %v", b.N, revised.Filesize/proto4.SectorSize) + } +} diff --git a/testutil/host.go b/testutil/host.go new file mode 100644 index 0000000..db7cddc --- /dev/null +++ b/testutil/host.go @@ -0,0 +1,395 @@ +package testutil + +import ( + "crypto/ed25519" + "errors" + "net" + "sync" + "testing" + + proto4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" + rhp4 "go.sia.tech/coreutils/rhp/v4/host" + "go.sia.tech/mux" + "go.uber.org/zap" +) + +// An EphemeralSectorStore is an in-memory minimal rhp4.SectorStore for testing. +type EphemeralSectorStore struct { + mu sync.Mutex + sectors map[types.Hash256][proto4.SectorSize]byte +} + +var _ rhp4.SectorStore = (*EphemeralSectorStore)(nil) + +// ReadSector reads a sector from the EphemeralSectorStore. +func (es *EphemeralSectorStore) ReadSector(root types.Hash256) ([proto4.SectorSize]byte, error) { + es.mu.Lock() + defer es.mu.Unlock() + sector, ok := es.sectors[root] + if !ok { + return [proto4.SectorSize]byte{}, errors.New("sector not found") + } + return sector, nil +} + +// StoreSector stores a sector in the EphemeralSectorStore. +func (es *EphemeralSectorStore) StoreSector(root types.Hash256, sector *[proto4.SectorSize]byte, expiration uint64) error { + es.mu.Lock() + defer es.mu.Unlock() + es.sectors[root] = *sector + return nil +} + +// An EphemeralContractor is an in-memory minimal rhp4.Contractor for testing. +type EphemeralContractor struct { + mu sync.Mutex // protects the fields below + + tip types.ChainIndex + contractElements map[types.FileContractID]types.V2FileContractElement + contracts map[types.FileContractID]types.V2FileContract + roots map[types.FileContractID][]types.Hash256 + locks map[types.FileContractID]bool + + accounts map[proto4.Account]types.Currency +} + +var _ rhp4.Contractor = (*EphemeralContractor)(nil) + +// ContractElement returns the contract state element for the given contract ID. +func (ec *EphemeralContractor) ContractElement(contractID types.FileContractID) (types.ChainIndex, types.V2FileContractElement, error) { + ec.mu.Lock() + defer ec.mu.Unlock() + + element, ok := ec.contractElements[contractID] + if !ok { + return types.ChainIndex{}, types.V2FileContractElement{}, errors.New("contract not found") + } + return ec.tip, element, nil +} + +// LockV2Contract locks a contract and returns its current state. +func (ec *EphemeralContractor) LockV2Contract(contractID types.FileContractID) (rhp4.RevisionState, func(), error) { + ec.mu.Lock() + defer ec.mu.Unlock() + + if ec.locks[contractID] { + return rhp4.RevisionState{}, nil, errors.New("contract already locked") + } + ec.locks[contractID] = true + + rev, ok := ec.contracts[contractID] + if !ok { + return rhp4.RevisionState{}, nil, errors.New("contract not found") + } + + var once sync.Once + return rhp4.RevisionState{ + Revision: rev, + Roots: ec.roots[contractID], + }, func() { + once.Do(func() { + ec.mu.Lock() + defer ec.mu.Unlock() + ec.locks[contractID] = false + }) + }, nil +} + +// AddV2Contract adds a new contract to the host. +func (ec *EphemeralContractor) AddV2Contract(formationSet rhp4.TransactionSet, _ rhp4.Usage) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + if len(formationSet.TransactionSet) == 0 { + return errors.New("expected at least one transaction") + } + formationTxn := formationSet.TransactionSet[len(formationSet.TransactionSet)-1] + if len(formationTxn.FileContracts) != 1 { + return errors.New("expected exactly one contract") + } + fc := formationTxn.FileContracts[0] + + contractID := formationTxn.V2FileContractID(formationTxn.ID(), 0) + if _, ok := ec.contracts[contractID]; ok { + return errors.New("contract already exists") + } + ec.contracts[contractID] = fc + ec.roots[contractID] = []types.Hash256{} + return nil +} + +// RenewV2Contract finalizes an existing contract and adds the renewed contract +// to the host. The existing contract must be locked before calling this method. +func (ec *EphemeralContractor) RenewV2Contract(renewalSet rhp4.TransactionSet, _ rhp4.Usage) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + if len(renewalSet.TransactionSet) == 0 { + return errors.New("expected at least one transaction") + } + renewalTxn := renewalSet.TransactionSet[len(renewalSet.TransactionSet)-1] + if len(renewalTxn.FileContractResolutions) != 1 { + return errors.New("expected exactly one resolution") + } + resolution := renewalTxn.FileContractResolutions[0] + renewal, ok := resolution.Resolution.(*types.V2FileContractRenewal) + if !ok { + return errors.New("expected renewal resolution") + } + existingID := types.FileContractID(resolution.Parent.ID) + + existing, ok := ec.contracts[existingID] + if !ok { + return errors.New("contract not found") + } else if existing.RevisionNumber == types.MaxRevisionNumber { + return errors.New("contract already at max revision") + } + + contractID := existingID.V2RenewalID() + if _, ok := ec.contracts[contractID]; ok { + return errors.New("contract already exists") + } + + ec.contracts[existingID] = renewal.FinalRevision + ec.contracts[contractID] = renewal.NewContract + ec.roots[contractID] = append([]types.Hash256(nil), ec.roots[existingID]...) + return nil +} + +// ReviseV2Contract atomically revises a contract and updates its sector roots +// and usage. +func (ec *EphemeralContractor) ReviseV2Contract(contractID types.FileContractID, revision types.V2FileContract, roots []types.Hash256, _ rhp4.Usage) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + existing, ok := ec.contracts[contractID] + if !ok { + return errors.New("contract not found") + } else if revision.RevisionNumber <= existing.RevisionNumber { + return errors.New("revision number must be greater than existing") + } + + ec.contracts[contractID] = revision + ec.roots[contractID] = append([]types.Hash256(nil), roots...) + return nil +} + +// AccountBalance returns the balance of an account. +func (ec *EphemeralContractor) AccountBalance(account proto4.Account) (types.Currency, error) { + ec.mu.Lock() + defer ec.mu.Unlock() + balance, ok := ec.accounts[account] + if !ok { + return types.Currency{}, errors.New("account not found") + } + return balance, nil +} + +// CreditAccountsWithContract credits accounts with the given deposits and +// revises the contract revision. The contract must be locked before calling +// this method. +func (ec *EphemeralContractor) CreditAccountsWithContract(deposits []proto4.AccountDeposit, contractID types.FileContractID, revision types.V2FileContract) ([]types.Currency, error) { + ec.mu.Lock() + defer ec.mu.Unlock() + + var balance = make([]types.Currency, 0, len(deposits)) + for _, deposit := range deposits { + ec.accounts[deposit.Account] = ec.accounts[deposit.Account].Add(deposit.Amount) + balance = append(balance, ec.accounts[deposit.Account]) + } + ec.contracts[contractID] = revision + return balance, nil +} + +// DebitAccount debits an account by the given amount. +func (ec *EphemeralContractor) DebitAccount(account proto4.Account, amount types.Currency) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + balance, ok := ec.accounts[account] + if !ok { + return errors.New("account not found") + } else if balance.Cmp(amount) < 0 { + return errors.New("insufficient funds") + } + ec.accounts[account] = balance.Sub(amount) + return nil +} + +// UpdateChainState updates the EphemeralContractor's state based on the +// reverted and applied chain updates. +func (ec *EphemeralContractor) UpdateChainState(reverted []chain.RevertUpdate, applied []chain.ApplyUpdate) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + for _, cru := range reverted { + cru.ForEachV2FileContractElement(func(fce types.V2FileContractElement, created bool, rev *types.V2FileContractElement, res types.V2FileContractResolutionType) { + if _, ok := ec.contracts[types.FileContractID(fce.ID)]; !ok { + return + } + + switch { + case created: + delete(ec.contractElements, types.FileContractID(fce.ID)) + case res != nil: + ec.contractElements[types.FileContractID(fce.ID)] = fce + case rev != nil: + ec.contractElements[types.FileContractID(fce.ID)] = *rev + } + }) + + for id, fce := range ec.contractElements { + cru.UpdateElementProof(&fce.StateElement) + ec.contractElements[id] = fce + } + ec.tip = cru.State.Index + } + for _, cau := range applied { + cau.ForEachV2FileContractElement(func(fce types.V2FileContractElement, created bool, rev *types.V2FileContractElement, res types.V2FileContractResolutionType) { + if _, ok := ec.contracts[types.FileContractID(fce.ID)]; !ok { + return + } + + switch { + case created: + ec.contractElements[types.FileContractID(fce.ID)] = fce + case res != nil: + delete(ec.contractElements, types.FileContractID(fce.ID)) + case rev != nil: + ec.contractElements[types.FileContractID(fce.ID)] = *rev + } + }) + + for id, fce := range ec.contractElements { + cau.UpdateElementProof(&fce.StateElement) + ec.contractElements[id] = fce + } + ec.tip = cau.State.Index + } + return nil +} + +// Tip returns the current chain tip. +func (ec *EphemeralContractor) Tip() types.ChainIndex { + ec.mu.Lock() + defer ec.mu.Unlock() + return ec.tip +} + +// An EphemeralSettingsReporter is an in-memory minimal rhp4.SettingsReporter +// for testing. +type EphemeralSettingsReporter struct { + mu sync.Mutex + settings proto4.HostSettings +} + +var _ rhp4.SettingsReporter = (*EphemeralSettingsReporter)(nil) + +// RHP4Settings implements the rhp4.SettingsReporter interface. +func (esr *EphemeralSettingsReporter) RHP4Settings() proto4.HostSettings { + esr.mu.Lock() + defer esr.mu.Unlock() + return esr.settings +} + +// Update updates the settings reported by the EphemeralSettingsReporter. +func (esr *EphemeralSettingsReporter) Update(settings proto4.HostSettings) { + esr.mu.Lock() + defer esr.mu.Unlock() + esr.settings = settings +} + +// A muxTransport is a rhp4.Transport that wraps a mux.Mux. +type muxTransport struct { + m *mux.Mux +} + +// Close implements the rhp4.Transport interface. +func (mt *muxTransport) Close() error { + return mt.m.Close() +} + +// AcceptStream implements the rhp4.Transport interface. +func (mt *muxTransport) AcceptStream() (net.Conn, error) { + return mt.m.AcceptStream() +} + +// ServeSiaMux starts a RHP4 host listening on a random port and returns the address. +func ServeSiaMux(tb testing.TB, s *rhp4.Server, log *zap.Logger) string { + l, err := net.Listen("tcp", "localhost:0") + if err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { l.Close() }) + + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + log := log.With(zap.Stringer("peerAddress", conn.RemoteAddr())) + + go func() { + defer conn.Close() + m, err := mux.Accept(conn, ed25519.PrivateKey(s.HostKey())) + if err != nil { + panic(err) + } + s.Serve(&muxTransport{m}, log) + }() + } + }() + return l.Addr().String() +} + +// NewEphemeralSettingsReporter creates an EphemeralSettingsReporter for testing. +func NewEphemeralSettingsReporter() *EphemeralSettingsReporter { + return &EphemeralSettingsReporter{} +} + +// NewEphemeralSectorStore creates an EphemeralSectorStore for testing. +func NewEphemeralSectorStore() *EphemeralSectorStore { + return &EphemeralSectorStore{ + sectors: make(map[types.Hash256][proto4.SectorSize]byte), + } +} + +// NewEphemeralContractor creates an EphemeralContractor for testing. +func NewEphemeralContractor(cm *chain.Manager) *EphemeralContractor { + ec := &EphemeralContractor{ + contractElements: make(map[types.FileContractID]types.V2FileContractElement), + contracts: make(map[types.FileContractID]types.V2FileContract), + roots: make(map[types.FileContractID][]types.Hash256), + locks: make(map[types.FileContractID]bool), + accounts: make(map[proto4.Account]types.Currency), + } + + reorgCh := make(chan types.ChainIndex, 1) + cm.OnReorg(func(index types.ChainIndex) { + select { + case reorgCh <- index: + default: + } + }) + + go func() { + for range reorgCh { + for { + reverted, applied, err := cm.UpdatesSince(ec.tip, 1000) + if err != nil { + panic(err) + } else if len(reverted) == 0 && len(applied) == 0 { + break + } + + if err := ec.UpdateChainState(reverted, applied); err != nil { + panic(err) + } + } + } + }() + return ec +} diff --git a/testutil/testutil.go b/testutil/testutil.go index f136ea5..d18cb46 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -43,13 +43,15 @@ func V2Network() (*consensus.Network, types.Block) { } // MineBlocks mines n blocks with the reward going to the given address. -func MineBlocks(t *testing.T, cm *chain.Manager, addr types.Address, n int) { +func MineBlocks(tb testing.TB, cm *chain.Manager, addr types.Address, n int) { + tb.Helper() + for ; n > 0; n-- { b, ok := coreutils.MineBlock(cm, addr, time.Second) if !ok { - t.Fatal("failed to mine block") + tb.Fatal("failed to mine block") } else if err := cm.AddBlocks([]types.Block{b}); err != nil { - t.Fatal(err) + tb.Fatal(err) } } }