diff --git a/chain/chain_test.go b/chain/chain_test.go index b3e5dd9..8f2ac16 100644 --- a/chain/chain_test.go +++ b/chain/chain_test.go @@ -7,6 +7,7 @@ import ( "go.sia.tech/core/types" "go.sia.tech/coreutils" "go.sia.tech/coreutils/chain" + "go.sia.tech/coreutils/testutil" "lukechampine.com/frand" ) @@ -113,11 +114,7 @@ func newMemState() *memState { } func TestV2Attestations(t *testing.T) { - n, genesisBlock := chain.TestnetZen() - - n.InitialTarget = types.BlockID{0xFF} - n.HardforkV2.AllowHeight = 2 - n.HardforkV2.RequireHeight = 3 + n, genesisBlock := testutil.V2Network() policy := types.AnyoneCanSpend() addr := policy.Address() @@ -144,7 +141,7 @@ func TestV2Attestations(t *testing.T) { ms := newMemState() // mine until a utxo is spendable - mineBlocks(t, cm, 150) + mineBlocks(t, cm, int(n.MaturityDelay)+1) ms.Sync(t, cm) txn := types.V2Transaction{ @@ -179,13 +176,12 @@ func TestV2Attestations(t *testing.T) { ms := newMemState() // mine until a utxo is spendable - mineBlocks(t, cm, 150) + mineBlocks(t, cm, int(n.MaturityDelay)+1) ms.Sync(t, cm) sk := types.GeneratePrivateKey() - ann := chain.HostAnnouncement{ - NetAddress: "foo.bar:1234", - PublicKey: sk.PublicKey(), + ann := chain.V2HostAnnouncement{ + {Address: "foo.bar:1234", Protocol: "tcp"}, } se := ms.SpendableElement(t) txn := types.V2Transaction{ @@ -229,13 +225,12 @@ func TestV2Attestations(t *testing.T) { ms := newMemState() // mine until a utxo is spendable - mineBlocks(t, cm, 150) + mineBlocks(t, cm, int(n.MaturityDelay)+1) ms.Sync(t, cm) sk := types.GeneratePrivateKey() - ann := chain.HostAnnouncement{ - NetAddress: "foo.bar:1234", - PublicKey: sk.PublicKey(), + ann := chain.V2HostAnnouncement{ + {Address: "foo.bar:1234", Protocol: "tcp"}, } txn := types.V2Transaction{ ArbitraryData: frand.Bytes(16), @@ -274,13 +269,12 @@ func TestV2Attestations(t *testing.T) { ms := newMemState() // mine until a utxo is spendable - mineBlocks(t, cm, 150) + mineBlocks(t, cm, int(n.MaturityDelay)+1) ms.Sync(t, cm) sk := types.GeneratePrivateKey() - ann := chain.HostAnnouncement{ - NetAddress: "foo.bar:1234", - PublicKey: sk.PublicKey(), + ann := chain.V2HostAnnouncement{ + {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 9acf99d..7a0bab8 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" @@ -11,36 +12,66 @@ 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 { - PublicKey types.PublicKey `json:"publicKey"` - NetAddress string `json:"netAddress"` +type ( + // 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). + HostAnnouncement struct { + PublicKey types.PublicKey `json:"publicKey"` + NetAddress string `json:"netAddress"` + } + + // A Protocol is a string identifying a network protocol that a host may be + // reached on. + Protocol string + + // A NetAddress is a pair of protocol and address that a host may be reached on + NetAddress struct { + Protocol Protocol `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(string(na.Protocol)) + e.WriteString(na.Address) +} + +// DecodeFrom implements types.DecoderFrom. +func (na *NetAddress) DecodeFrom(d *types.Decoder) { + na.Protocol = 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 { - if ha.PublicKey != sk.PublicKey() { - panic("key mismatch") // developer error +func (ha V2HostAnnouncement) ToAttestation(cs consensus.State, sk types.PrivateKey) types.Attestation { + buf := bytes.NewBuffer(nil) + e := types.NewEncoder(buf) + types.EncodeSlice(e, ha) + if err := e.Flush(); err != nil { + panic(err) // should never happen } a := types.Attestation{ - PublicKey: ha.PublicKey, + PublicKey: sk.PublicKey(), Key: attestationHostAnnouncement, - Value: []byte(ha.NetAddress), + Value: buf.Bytes(), } a.Signature = sk.SignHash(cs.AttestationSigHash(a)) return a } // FromAttestation decodes a host announcement from an attestation. -func (ha *HostAnnouncement) FromAttestation(a types.Attestation) bool { +func (ha *V2HostAnnouncement) FromAttestation(a types.Attestation) error { if a.Key != attestationHostAnnouncement { - return false + return errors.New("not a host announcement") } - ha.PublicKey = a.PublicKey - 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. @@ -93,11 +124,15 @@ func ForEachHostAnnouncement(b types.Block, fn func(HostAnnouncement)) { } } } +} + +// 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) { - fn(ha) + 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 165502c..c1df68b 100644 --- a/chain/hostannouncement_test.go +++ b/chain/hostannouncement_test.go @@ -1,10 +1,13 @@ package chain import ( + "encoding/binary" + "math" "testing" "go.sia.tech/core/consensus" "go.sia.tech/core/types" + "lukechampine.com/frand" ) func TestForEachHostAnnouncement(t *testing.T) { @@ -17,11 +20,6 @@ func TestForEachHostAnnouncement(t *testing.T) { Transactions: []types.Transaction{ {ArbitraryData: [][]byte{ha.ToArbitraryData(sk)}}, }, - V2: &types.V2BlockData{ - Transactions: []types.V2Transaction{ - {Attestations: []types.Attestation{ha.ToAttestation(consensus.State{}, sk)}}, - }, - }, } ForEachHostAnnouncement(b, func(a HostAnnouncement) { if a.PublicKey != sk.PublicKey() { @@ -31,3 +29,53 @@ func TestForEachHostAnnouncement(t *testing.T) { } }) } + +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"}, + }) + randomAttestation := types.Attestation{ + PublicKey: sk.PublicKey(), + Key: "foo", + Value: frand.Bytes(60), + } + cs := consensus.State{} + randomAttestation.Signature = sk.SignHash(cs.AttestationSigHash(randomAttestation)) + + invalidData := make([]byte, 100) + binary.LittleEndian.PutUint64(invalidData, math.MaxUint64) + extraBigAttestation := types.Attestation{ + PublicKey: sk.PublicKey(), + Key: attestationHostAnnouncement, + Value: invalidData, + } + extraBigAttestation.Signature = sk.SignHash(cs.AttestationSigHash(extraBigAttestation)) + + b := types.Block{ + V2: &types.V2BlockData{ + Transactions: []types.V2Transaction{ + {Attestations: []types.Attestation{ + randomAttestation, + extraBigAttestation, + ha.ToAttestation(consensus.State{}, sk), + }}, + }, + }, + } + ForEachV2HostAnnouncement(b, func(pk types.PublicKey, addresses []NetAddress) { + if pk != sk.PublicKey() { + t.Error("pubkey mismatch") + } 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..22440f2 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() @@ -949,7 +949,7 @@ func (m *Manager) V2TransactionSet(basis types.ChainIndex, txn types.V2Transacti m.revalidatePool() // update the transaction's basis to match tip - _, txns, err := m.updateV2TransactionSet(basis, []types.V2Transaction{txn}) + txns, err := m.updateV2TransactionProofs([]types.V2Transaction{txn}, basis, m.tipState.Index) if err != nil { return types.ChainIndex{}, nil, fmt.Errorf("failed to update transaction set basis: %w", err) } else if len(txns) == 0 { @@ -1041,82 +1041,25 @@ func (m *Manager) markBadTxnSet(setID types.Hash256, err error) error { return err } -// AddPoolTransactions validates a transaction set and adds it to the txpool. If -// any transaction references an element (SiacoinOutput, SiafundOutput, or -// FileContract) not present in the blockchain, that element must be created by -// a previous transaction in the set. -// -// If any transaction in the set is invalid, the entire set is rejected and none -// of the transactions are added to the pool. If all of the transactions are -// already known to the pool, AddPoolTransactions returns true. -func (m *Manager) AddPoolTransactions(txns []types.Transaction) (known bool, err error) { - m.mu.Lock() - defer m.mu.Unlock() - m.revalidatePool() - - setID, known := m.checkDupTxnSet(txns, nil) - if known { - return true, m.txpool.invalidTxnSets[setID] - } - - // validate as a standalone set - ms := consensus.NewMidState(m.tipState) - for _, txn := range txns { - ts := m.store.SupplementTipTransaction(txn) - if err := consensus.ValidateTransaction(ms, txn, ts); err != nil { - return false, m.markBadTxnSet(setID, fmt.Errorf("transaction %v is invalid: %w", txn.ID(), err)) - } - ms.ApplyTransaction(txn, ts) - } - - for _, txn := range txns { - txid := txn.ID() - if _, ok := m.txpool.indices[txid]; ok { - continue // skip transactions already in pool - } - m.txpool.ms.ApplyTransaction(txn, m.store.SupplementTipTransaction(txn)) - m.txpool.indices[txid] = len(m.txpool.txns) - m.txpool.txns = append(m.txpool.txns, txn) - m.txpool.weight += m.tipState.TransactionWeight(txn) - } - - // invalidate caches - m.txpool.medianFee = nil - m.txpool.parentMap = nil - return -} - -// updateV2TransactionSet updates the basis of a transaction set to the current -// tip. If the basis is already the tip, the transaction set is returned as-is. -// Any transactions that were confirmed are removed from the set. Any ephemeral -// state elements that were created by an update are updated. -// -// If it is undesirable to modify the transaction set, deep-copy it -// before calling this method. -func (m *Manager) updateV2TransactionSet(basis types.ChainIndex, txns []types.V2Transaction) (types.ChainIndex, []types.V2Transaction, error) { - if basis == m.tipState.Index { - return basis, txns, nil - } - - // bring txns up-to-date - revert, apply, err := m.reorgPath(basis, m.tipState.Index) +func (m *Manager) updateV2TransactionProofs(txns []types.V2Transaction, from, to types.ChainIndex) ([]types.V2Transaction, error) { + revert, apply, err := m.reorgPath(from, to) if err != nil { - return types.ChainIndex{}, nil, fmt.Errorf("couldn't determine reorg path from %v to %v: %w", basis, m.tipState.Index, err) + return nil, fmt.Errorf("couldn't determine reorg path from %v to %v: %w", from, to, err) } else if len(revert)+len(apply) > 144 { - return types.ChainIndex{}, nil, fmt.Errorf("reorg path from %v to %v is too long (-%v +%v)", basis, m.tipState.Index, len(revert), len(apply)) + return nil, fmt.Errorf("reorg path from %v to %v is too long (-%v +%v)", from, to, len(revert), len(apply)) } for _, index := range revert { b, _, cs, ok := blockAndParent(m.store, index.ID) if !ok { - return types.ChainIndex{}, nil, fmt.Errorf("missing reverted block at index %v", index) + return nil, fmt.Errorf("missing reverted block at index %v", index) } else if b.V2 == nil { - return types.ChainIndex{}, nil, fmt.Errorf("reorg path from %v to %v contains a non-v2 block (%v)", basis, m.tipState.Index, index) + return nil, fmt.Errorf("reorg path from %v to %v contains a non-v2 block (%v)", from, to, index) } // NOTE: since we are post-hardfork, we don't need a v1 supplement cru := consensus.RevertBlock(cs, b, consensus.V1BlockSupplement{}) for i := range txns { if !updateTxnProofs(&txns[i], cru.UpdateElementProof, cs.Elements.NumLeaves) { - return types.ChainIndex{}, nil, fmt.Errorf("transaction %v references element that does not exist in our chain", txns[i].ID()) + return nil, fmt.Errorf("transaction %v references element that does not exist in our chain", txns[i].ID()) } } } @@ -1124,9 +1067,9 @@ func (m *Manager) updateV2TransactionSet(basis types.ChainIndex, txns []types.V2 for _, index := range apply { b, _, cs, ok := blockAndParent(m.store, index.ID) if !ok { - return types.ChainIndex{}, nil, fmt.Errorf("missing applied block at index %v", index) + return nil, fmt.Errorf("missing applied block at index %v", index) } else if b.V2 == nil { - return types.ChainIndex{}, nil, fmt.Errorf("reorg path from %v to %v contains a non-v2 block (%v)", basis, m.tipState.Index, index) + return nil, fmt.Errorf("reorg path from %v to %v contains a non-v2 block (%v)", from, to, index) } // NOTE: since we are post-hardfork, we don't need a v1 supplement or ancestorTimestamp cs, cau := consensus.ApplyBlock(cs, b, consensus.V1BlockSupplement{}, time.Time{}) @@ -1154,7 +1097,6 @@ func (m *Manager) updateV2TransactionSet(basis types.ChainIndex, txns []types.V2 // remove any transactions that were confirmed in this block continue } - rem = append(rem, txns[i]) // update the state elements for any confirmed ephemeral elements for j := range txns[i].SiacoinInputs { @@ -1182,11 +1124,74 @@ func (m *Manager) updateV2TransactionSet(basis types.ChainIndex, txns []types.V2 // NOTE: all elements guaranteed to exist from here on, so no // need to check this return value - updateTxnProofs(&rem[len(rem)-1], cau.UpdateElementProof, cs.Elements.NumLeaves) + updateTxnProofs(&txns[i], cau.UpdateElementProof, cs.Elements.NumLeaves) + rem = append(rem, txns[i]) } txns = rem } - return m.tipState.Index, txns, nil + return txns, nil +} + +// AddPoolTransactions validates a transaction set and adds it to the txpool. If +// any transaction references an element (SiacoinOutput, SiafundOutput, or +// FileContract) not present in the blockchain, that element must be created by +// a previous transaction in the set. +// +// If any transaction in the set is invalid, the entire set is rejected and none +// of the transactions are added to the pool. If all of the transactions are +// already known to the pool, AddPoolTransactions returns true. +func (m *Manager) AddPoolTransactions(txns []types.Transaction) (known bool, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.revalidatePool() + + setID, known := m.checkDupTxnSet(txns, nil) + if known { + return true, m.txpool.invalidTxnSets[setID] + } + + // validate as a standalone set + ms := consensus.NewMidState(m.tipState) + for _, txn := range txns { + ts := m.store.SupplementTipTransaction(txn) + if err := consensus.ValidateTransaction(ms, txn, ts); err != nil { + return false, m.markBadTxnSet(setID, fmt.Errorf("transaction %v is invalid: %w", txn.ID(), err)) + } + ms.ApplyTransaction(txn, ts) + } + + for _, txn := range txns { + txid := txn.ID() + if _, ok := m.txpool.indices[txid]; ok { + continue // skip transactions already in pool + } + m.txpool.ms.ApplyTransaction(txn, m.store.SupplementTipTransaction(txn)) + m.txpool.indices[txid] = len(m.txpool.txns) + m.txpool.txns = append(m.txpool.txns, txn) + m.txpool.weight += m.tipState.TransactionWeight(txn) + } + + // invalidate caches + m.txpool.medianFee = nil + m.txpool.parentMap = nil + return false, nil +} + +// UpdateV2TransactionSet updates the basis of a transaction set from "from" to "to". +// If from and to are equal, the transaction set is returned as-is. +// Any transactions that were confirmed are removed from the set. +// Any ephemeral state elements that were created by an update are updated. +// +// If it is undesirable to modify the transaction set, deep-copy it +// before calling this method. +func (m *Manager) UpdateV2TransactionSet(txns []types.V2Transaction, from, to types.ChainIndex) ([]types.V2Transaction, error) { + if from == to { + return txns, nil + } + + m.mu.Lock() + defer m.mu.Unlock() + return m.updateV2TransactionProofs(txns, from, to) } // AddV2PoolTransactions validates a transaction set and adds it to the txpool. @@ -1220,7 +1225,7 @@ func (m *Manager) AddV2PoolTransactions(basis types.ChainIndex, txns []types.V2T } // update the transaction set to the current tip - _, txns, err := m.updateV2TransactionSet(basis, txns) + txns, err := m.updateV2TransactionProofs(txns, basis, m.tipState.Index) if err != nil { return false, m.markBadTxnSet(setID, fmt.Errorf("failed to update set basis: %w", err)) } else if len(txns) == 0 { diff --git a/go.mod b/go.mod index cee1523..c5f5af2 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,14 @@ toolchain go1.23.2 require ( go.etcd.io/bbolt v1.3.11 - go.sia.tech/core v0.5.0 + go.sia.tech/core v0.5.1-0.20241028140321-8319d4147268 + go.sia.tech/mux v1.3.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.28.0 lukechampine.com/frand v1.5.1 ) require ( - go.sia.tech/mux v1.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sys v0.26.0 // indirect ) diff --git a/go.sum b/go.sum index bfa1fcb..e6091da 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,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.5.0 h1:feLC7DSCF+PhU157s/94106hFKyiGrGQ9HC3/dF/l7E= -go.sia.tech/core v0.5.0/go.mod h1:P3C1BWa/7J4XgdzWuaYHBvLo2RzZ0UBaJM4TG1GWB2g= +go.sia.tech/core v0.5.1-0.20241028140321-8319d4147268 h1:Afh3x9rg6pI183LQVYIGQ3quhrRgHWez4987JSKmTpk= +go.sia.tech/core v0.5.1-0.20241028140321-8319d4147268/go.mod h1:P3C1BWa/7J4XgdzWuaYHBvLo2RzZ0UBaJM4TG1GWB2g= go.sia.tech/mux v1.3.0 h1:hgR34IEkqvfBKUJkAzGi31OADeW2y7D6Bmy/Jcbop9c= go.sia.tech/mux v1.3.0/go.mod h1:I46++RD4beqA3cW9Xm9SwXbezwPqLvHhVs9HLpDtt58= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= diff --git a/rhp/v4/options.go b/rhp/v4/options.go new file mode 100644 index 0000000..d668025 --- /dev/null +++ b/rhp/v4/options.go @@ -0,0 +1,23 @@ +package rhp + +import ( + "time" +) + +// An ServerOption sets an option on a Server. +type ServerOption func(*Server) + +// 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/rpc.go b/rhp/v4/rpc.go new file mode 100644 index 0000000..bbdb169 --- /dev/null +++ b/rhp/v4/rpc.go @@ -0,0 +1,910 @@ +package rhp + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + + "go.sia.tech/core/consensus" + rhp4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + "lukechampine.com/frand" +) + +var ( + // ErrInvalidRoot is returned when RPCWrite returns a sector root that does + // not match the expected value. + ErrInvalidRoot = errors.New("invalid root") + // ErrInvalidProof is returned when an RPC returns an invalid Merkle proof. + ErrInvalidProof = errors.New("invalid proof") +) + +var zeros = zeroReader{} + +type zeroReader struct{} + +func (r zeroReader) Read(p []byte) (int, error) { + clear(p) + return len(p), nil +} + +type ( + // A TransportClient is a generic multiplexer for outgoing streams. + TransportClient interface { + DialStream(context.Context) net.Conn + + FrameSize() int + PeerKey() types.PublicKey + + Close() error + } + + // A ReaderLen is an io.Reader that also provides the length method. + ReaderLen interface { + io.Reader + Len() (int, error) + } + + // A TxPool manages the transaction pool. + TxPool interface { + // V2TransactionSet returns the full transaction set and basis necessary + // for broadcasting a transaction. The transaction will be updated if + // the provided basis does not match the current tip. 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) + // RecommendedFee returns the recommended fee per byte for a transaction. + RecommendedFee() types.Currency + } + + // A ContractSigner is a minimal interface for contract signing. + ContractSigner interface { + SignHash(types.Hash256) types.Signature + } + + // A TransactionInputSigner is an interface for signing v2 transactions using + // a single private key. + TransactionInputSigner interface { + SignV2Inputs(*types.V2Transaction, []int) + } + + // A TransactionFunder is an interface for funding v2 transactions. + TransactionFunder interface { + FundV2Transaction(txn *types.V2Transaction, amount types.Currency) (types.ChainIndex, []int, error) + ReleaseInputs([]types.V2Transaction) + } + + // FormContractSigner is the minimal interface required to fund and sign a + // contract formation transaction. + FormContractSigner interface { + ContractSigner + TransactionInputSigner + TransactionFunder + } +) + +type ( + // A TransactionSet is a set of transactions that are valid as of the + // provided chain index. + TransactionSet struct { + Basis types.ChainIndex `json:"basis"` + Transactions []types.V2Transaction `json:"transactions"` + } + + // ContractRevision pairs a contract ID with a revision. + ContractRevision struct { + ID types.FileContractID `json:"id"` + Revision types.V2FileContract `json:"revision"` + } +) + +type ( + // An AccountBalance pairs an account with its current balance. + AccountBalance struct { + Account rhp4.Account `json:"account"` + Balance types.Currency `json:"balance"` + } + + // RPCWriteSectorResult contains the result of executing the write sector RPC. + RPCWriteSectorResult struct { + Root types.Hash256 `json:"root"` + Usage rhp4.Usage `json:"usage"` + } + + // RPCReadSectorResult contains the result of executing the read sector RPC. + RPCReadSectorResult struct { + Usage rhp4.Usage `json:"usage"` + } + + // RPCVerifySectorResult contains the result of executing the verify sector RPC. + RPCVerifySectorResult struct { + Usage rhp4.Usage `json:"usage"` + } + + // RPCFreeSectorsResult contains the result of executing the remove sectors RPC. + RPCFreeSectorsResult struct { + Revision types.V2FileContract `json:"revision"` + Usage rhp4.Usage `json:"usage"` + } + + // RPCAppendSectorsResult contains the result of executing the append sectors + // RPC. + RPCAppendSectorsResult struct { + Revision types.V2FileContract `json:"revision"` + Usage rhp4.Usage `json:"usage"` + Sectors []types.Hash256 `json:"sectors"` + } + + // RPCFundAccountResult contains the result of executing the fund accounts RPC. + RPCFundAccountResult struct { + Revision types.V2FileContract `json:"revision"` + Balances []AccountBalance `json:"balances"` + Usage rhp4.Usage `json:"usage"` + } + + // RPCSectorRootsResult contains the result of executing the sector roots RPC. + RPCSectorRootsResult struct { + Revision types.V2FileContract `json:"revision"` + Roots []types.Hash256 `json:"roots"` + Usage rhp4.Usage `json:"usage"` + } + + // RPCFormContractResult contains the result of executing the form contract RPC. + RPCFormContractResult struct { + Contract ContractRevision `json:"contract"` + FormationSet TransactionSet `json:"formationSet"` + Cost types.Currency `json:"cost"` + Usage rhp4.Usage `json:"usage"` + } + + // RPCRenewContractResult contains the result of executing the renew contract RPC. + RPCRenewContractResult struct { + Contract ContractRevision `json:"contract"` + RenewalSet TransactionSet `json:"renewalSet"` + Cost types.Currency `json:"cost"` + Usage rhp4.Usage `json:"usage"` + } + + // RPCRefreshContractResult contains the result of executing the refresh contract RPC. + RPCRefreshContractResult struct { + Contract ContractRevision `json:"contract"` + RenewalSet TransactionSet `json:"renewalSet"` + Cost types.Currency `json:"cost"` + Usage rhp4.Usage `json:"usage"` + } +) + +func callSingleRoundtripRPC(ctx context.Context, t TransportClient, rpcID types.Specifier, req, resp rhp4.Object) error { + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rpcID, req); err != nil { + return fmt.Errorf("failed to write request: %w", err) + } else if err := rhp4.ReadResponse(s, resp); err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + return nil +} + +// RPCSettings returns the current settings of the host. +func RPCSettings(ctx context.Context, t TransportClient) (rhp4.HostSettings, error) { + var resp rhp4.RPCSettingsResponse + err := callSingleRoundtripRPC(ctx, t, rhp4.RPCSettingsID, nil, &resp) + return resp.Settings, err +} + +// RPCReadSector reads a sector from the host. +func RPCReadSector(ctx context.Context, t TransportClient, prices rhp4.HostPrices, token rhp4.AccountToken, w io.Writer, root types.Hash256, offset, length uint64) (RPCReadSectorResult, error) { + req := &rhp4.RPCReadSectorRequest{ + Prices: prices, + Token: token, + Root: root, + Offset: offset, + Length: length, + } + if err := req.Validate(t.PeerKey()); err != nil { + return RPCReadSectorResult{}, fmt.Errorf("invalid request: %w", err) + } + + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCReadSectorID, req); err != nil { + return RPCReadSectorResult{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCReadSectorStreamedResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return RPCReadSectorResult{}, fmt.Errorf("failed to read response: %w", err) + } + + start := req.Offset / rhp4.LeafSize + end := (req.Offset + req.Length + rhp4.LeafSize - 1) / rhp4.LeafSize + rpv := rhp4.NewRangeProofVerifier(start, end) + if n, err := rpv.ReadFrom(io.TeeReader(io.LimitReader(s, int64(resp.DataLength)), w)); err != nil { + return RPCReadSectorResult{}, fmt.Errorf("failed to read data: %w", err) + } else if !rpv.Verify(resp.Proof, root) { + return RPCReadSectorResult{}, ErrInvalidProof + } else if n != int64(resp.DataLength) { + return RPCReadSectorResult{}, io.ErrUnexpectedEOF + } + return RPCReadSectorResult{ + Usage: prices.RPCReadSectorCost(length), + }, nil +} + +// RPCWriteSector writes a sector to the host. +func RPCWriteSector(ctx context.Context, t TransportClient, prices rhp4.HostPrices, token rhp4.AccountToken, rl ReaderLen, duration uint64) (RPCWriteSectorResult, error) { + length, err := rl.Len() + if err != nil { + return RPCWriteSectorResult{}, fmt.Errorf("failed to get length: %w", err) + } else if length == 0 { + return RPCWriteSectorResult{}, errors.New("cannot write zero-length sector") + } + + req := rhp4.RPCWriteSectorStreamingRequest{ + Prices: prices, + Token: token, + Duration: duration, + DataLength: uint64(length), + } + + if err := req.Validate(t.PeerKey(), req.Duration); err != nil { + return RPCWriteSectorResult{}, fmt.Errorf("invalid request: %w", err) + } + + stream := t.DialStream(ctx) + defer stream.Close() + + bw := bufio.NewWriterSize(stream, t.FrameSize()) + + if err := rhp4.WriteRequest(bw, rhp4.RPCWriteSectorID, &req); err != nil { + return RPCWriteSectorResult{}, fmt.Errorf("failed to write request: %w", err) + } + + sr := io.LimitReader(rl, int64(req.DataLength)) + tr := io.TeeReader(sr, bw) + if req.DataLength < rhp4.SectorSize { + // if the data is less than a full sector, the reader needs to be padded + // with zeros to calculate the sector root + tr = io.MultiReader(tr, io.LimitReader(zeros, int64(rhp4.SectorSize-req.DataLength))) + } + + root, err := rhp4.ReaderRoot(tr) + if err != nil { + return RPCWriteSectorResult{}, fmt.Errorf("failed to calculate root: %w", err) + } else if err := bw.Flush(); err != nil { + return RPCWriteSectorResult{}, fmt.Errorf("failed to flush: %w", err) + } + + var resp rhp4.RPCWriteSectorResponse + if err := rhp4.ReadResponse(stream, &resp); err != nil { + return RPCWriteSectorResult{}, fmt.Errorf("failed to read response: %w", err) + } else if resp.Root != root { + return RPCWriteSectorResult{}, ErrInvalidRoot + } + + return RPCWriteSectorResult{ + Root: resp.Root, + Usage: prices.RPCWriteSectorCost(uint64(length), duration), + }, nil +} + +// RPCVerifySector verifies that the host is properly storing a sector +func RPCVerifySector(ctx context.Context, t TransportClient, prices rhp4.HostPrices, token rhp4.AccountToken, root types.Hash256) (RPCVerifySectorResult, error) { + req := rhp4.RPCVerifySectorRequest{ + Prices: prices, + Token: token, + Root: root, + LeafIndex: frand.Uint64n(rhp4.LeavesPerSector), + } + + var resp rhp4.RPCVerifySectorResponse + if err := callSingleRoundtripRPC(ctx, t, rhp4.RPCVerifySectorID, &req, &resp); err != nil { + return RPCVerifySectorResult{}, err + } else if !rhp4.VerifyLeafProof(resp.Proof, resp.Leaf, req.LeafIndex, root) { + return RPCVerifySectorResult{}, ErrInvalidProof + } + + return RPCVerifySectorResult{ + Usage: prices.RPCVerifySectorCost(), + }, nil +} + +// RPCFreeSectors removes sectors from a contract. +func RPCFreeSectors(ctx context.Context, t TransportClient, cs consensus.State, prices rhp4.HostPrices, sk types.PrivateKey, contract ContractRevision, indices []uint64) (RPCFreeSectorsResult, error) { + req := rhp4.RPCFreeSectorsRequest{ + ContractID: contract.ID, + Prices: prices, + Indices: indices, + } + req.ChallengeSignature = sk.SignHash(req.ChallengeSigHash(contract.Revision.RevisionNumber + 1)) + + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCFreeSectorsID, &req); err != nil { + return RPCFreeSectorsResult{}, fmt.Errorf("failed to write request: %w", err) + } + + numSectors := contract.Revision.Filesize / rhp4.SectorSize + var resp rhp4.RPCFreeSectorsResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return RPCFreeSectorsResult{}, fmt.Errorf("failed to read response: %w", err) + } else if !rhp4.VerifyFreeSectorsProof(resp.OldSubtreeHashes, resp.OldLeafHashes, indices, numSectors, contract.Revision.FileMerkleRoot, resp.NewMerkleRoot) { + return RPCFreeSectorsResult{}, ErrInvalidProof + } + + revision, usage, err := rhp4.ReviseForFreeSectors(contract.Revision, prices, resp.NewMerkleRoot, len(indices)) + if err != nil { + return RPCFreeSectorsResult{}, fmt.Errorf("failed to revise contract: %w", err) + } + + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = sk.SignHash(sigHash) + + signatureResp := rhp4.RPCFreeSectorsSecondResponse{ + RenterSignature: revision.RenterSignature, + } + if err := rhp4.WriteResponse(s, &signatureResp); err != nil { + return RPCFreeSectorsResult{}, fmt.Errorf("failed to write signature response: %w", err) + } + + var hostSignature rhp4.RPCFreeSectorsThirdResponse + if err := rhp4.ReadResponse(s, &hostSignature); err != nil { + return RPCFreeSectorsResult{}, fmt.Errorf("failed to read host signatures: %w", err) + } + // validate the host signature + if !contract.Revision.HostPublicKey.VerifyHash(sigHash, hostSignature.HostSignature) { + return RPCFreeSectorsResult{}, rhp4.ErrInvalidSignature + } + // return the signed revision + return RPCFreeSectorsResult{ + Revision: revision, + Usage: usage, + }, nil +} + +// RPCAppendSectors appends sectors a host is storing to a contract. +func RPCAppendSectors(ctx context.Context, t TransportClient, cs consensus.State, prices rhp4.HostPrices, sk types.PrivateKey, contract ContractRevision, roots []types.Hash256) (RPCAppendSectorsResult, error) { + req := rhp4.RPCAppendSectorsRequest{ + Prices: prices, + Sectors: roots, + ContractID: contract.ID, + } + req.ChallengeSignature = sk.SignHash(req.ChallengeSigHash(contract.Revision.RevisionNumber + 1)) + + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCAppendSectorsID, &req); err != nil { + return RPCAppendSectorsResult{}, fmt.Errorf("failed to write request: %w", err) + } + + var resp rhp4.RPCAppendSectorsResponse + if err := rhp4.ReadResponse(s, &resp); err != nil { + return RPCAppendSectorsResult{}, fmt.Errorf("failed to read response: %w", err) + } else if len(resp.Accepted) != len(roots) { + return RPCAppendSectorsResult{}, errors.New("host returned less roots") + } + appended := make([]types.Hash256, 0, len(roots)) + for i := range resp.Accepted { + if resp.Accepted[i] { + appended = append(appended, roots[i]) + } + } + numSectors := (contract.Revision.Filesize + rhp4.SectorSize - 1) / rhp4.SectorSize + if !rhp4.VerifyAppendSectorsProof(numSectors, resp.SubtreeRoots, appended, contract.Revision.FileMerkleRoot, resp.NewMerkleRoot) { + return RPCAppendSectorsResult{}, ErrInvalidProof + } + + revision, usage, err := rhp4.ReviseForAppendSectors(contract.Revision, prices, resp.NewMerkleRoot, uint64(len(appended))) + if err != nil { + return RPCAppendSectorsResult{}, fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = sk.SignHash(sigHash) + + signatureResp := rhp4.RPCAppendSectorsSecondResponse{ + RenterSignature: revision.RenterSignature, + } + if err := rhp4.WriteResponse(s, &signatureResp); err != nil { + return RPCAppendSectorsResult{}, fmt.Errorf("failed to write signature response: %w", err) + } + + var hostSignature rhp4.RPCAppendSectorsThirdResponse + if err := rhp4.ReadResponse(s, &hostSignature); err != nil { + return RPCAppendSectorsResult{}, fmt.Errorf("failed to read host signatures: %w", err) + } else if !contract.Revision.HostPublicKey.VerifyHash(sigHash, hostSignature.HostSignature) { + return RPCAppendSectorsResult{}, rhp4.ErrInvalidSignature + } + return RPCAppendSectorsResult{ + Revision: revision, + Usage: usage, + Sectors: appended, + }, nil +} + +// RPCFundAccounts funds accounts on the host. +func RPCFundAccounts(ctx context.Context, t TransportClient, cs consensus.State, signer ContractSigner, contract ContractRevision, deposits []rhp4.AccountDeposit) (RPCFundAccountResult, error) { + var total types.Currency + for _, deposit := range deposits { + total = total.Add(deposit.Amount) + } + revision, usage, err := rhp4.ReviseForFundAccounts(contract.Revision, total) + if err != nil { + return RPCFundAccountResult{}, fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = signer.SignHash(sigHash) + + req := rhp4.RPCFundAccountsRequest{ + ContractID: contract.ID, + Deposits: deposits, + RenterSignature: revision.RenterSignature, + } + + var resp rhp4.RPCFundAccountsResponse + if err := callSingleRoundtripRPC(ctx, t, rhp4.RPCFundAccountsID, &req, &resp); err != nil { + return RPCFundAccountResult{}, err + } + + // validate the response + if len(resp.Balances) != len(deposits) { + return RPCFundAccountResult{}, fmt.Errorf("expected %v balances, got %v", len(deposits), len(resp.Balances)) + } else if !contract.Revision.HostPublicKey.VerifyHash(sigHash, resp.HostSignature) { + return RPCFundAccountResult{}, rhp4.ErrInvalidSignature + } + revision.HostSignature = resp.HostSignature + + balances := make([]AccountBalance, 0, len(deposits)) + for i := range deposits { + balances = append(balances, AccountBalance{ + Account: deposits[i].Account, + Balance: resp.Balances[i], + }) + } + + return RPCFundAccountResult{ + Revision: revision, + Balances: balances, + Usage: usage, + }, nil +} + +// RPCLatestRevision returns the latest revision of a contract. +func RPCLatestRevision(ctx context.Context, t TransportClient, contractID types.FileContractID) (types.V2FileContract, error) { + req := rhp4.RPCLatestRevisionRequest{ContractID: contractID} + var resp rhp4.RPCLatestRevisionResponse + err := callSingleRoundtripRPC(ctx, t, rhp4.RPCLatestRevisionID, &req, &resp) + return resp.Contract, err +} + +// RPCSectorRoots returns the sector roots for a contract. +func RPCSectorRoots(ctx context.Context, t TransportClient, cs consensus.State, prices rhp4.HostPrices, signer ContractSigner, contract ContractRevision, offset, length uint64) (RPCSectorRootsResult, error) { + revision, usage, err := rhp4.ReviseForSectorRoots(contract.Revision, prices, length) + if err != nil { + return RPCSectorRootsResult{}, fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + revision.RenterSignature = signer.SignHash(sigHash) + + req := rhp4.RPCSectorRootsRequest{ + Prices: prices, + ContractID: contract.ID, + Offset: offset, + Length: length, + RenterSignature: revision.RenterSignature, + } + + if err := req.Validate(contract.Revision.HostPublicKey, revision, length); err != nil { + return RPCSectorRootsResult{}, fmt.Errorf("invalid request: %w", err) + } + + numSectors := (contract.Revision.Filesize + rhp4.SectorSize - 1) / rhp4.SectorSize + var resp rhp4.RPCSectorRootsResponse + if err := callSingleRoundtripRPC(ctx, t, rhp4.RPCSectorRootsID, &req, &resp); err != nil { + return RPCSectorRootsResult{}, err + } else if !rhp4.VerifySectorRootsProof(resp.Proof, resp.Roots, numSectors, offset, offset+length, contract.Revision.FileMerkleRoot) { + return RPCSectorRootsResult{}, ErrInvalidProof + } + + // validate host signature + if !contract.Revision.HostPublicKey.VerifyHash(sigHash, resp.HostSignature) { + return RPCSectorRootsResult{}, rhp4.ErrInvalidSignature + } + revision.HostSignature = resp.HostSignature + + return RPCSectorRootsResult{ + Revision: revision, + Roots: resp.Roots, + Usage: usage, + }, nil +} + +// RPCAccountBalance returns the balance of an account. +func RPCAccountBalance(ctx context.Context, t TransportClient, account rhp4.Account) (types.Currency, error) { + req := &rhp4.RPCAccountBalanceRequest{Account: account} + var resp rhp4.RPCAccountBalanceResponse + err := callSingleRoundtripRPC(ctx, t, rhp4.RPCAccountBalanceID, req, &resp) + return resp.Balance, err +} + +// RPCFormContract forms a contract with a host +func RPCFormContract(ctx context.Context, t TransportClient, tp TxPool, signer FormContractSigner, cs consensus.State, p rhp4.HostPrices, hostKey types.PublicKey, hostAddress types.Address, params rhp4.RPCFormContractParams) (RPCFormContractResult, error) { + fc, usage := rhp4.NewContract(p, params, hostKey, hostAddress) + formationTxn := types.V2Transaction{ + MinerFee: tp.RecommendedFee().Mul64(1000), + FileContracts: []types.V2FileContract{fc}, + } + + renterCost, _ := rhp4.ContractCost(cs, p, fc, formationTxn.MinerFee) + basis, toSign, err := signer.FundV2Transaction(&formationTxn, renterCost) + if err != nil { + return RPCFormContractResult{}, fmt.Errorf("failed to fund transaction: %w", err) + } + + basis, formationSet, err := tp.V2TransactionSet(basis, formationTxn) + if err != nil { + signer.ReleaseInputs([]types.V2Transaction{formationTxn}) + return RPCFormContractResult{}, fmt.Errorf("failed to get transaction set: %w", err) + } + formationTxn, formationSet = formationSet[len(formationSet)-1], formationSet[:len(formationSet)-1] + + renterSiacoinElements := make([]types.SiacoinElement, 0, len(formationTxn.SiacoinInputs)) + for _, i := range formationTxn.SiacoinInputs { + renterSiacoinElements = append(renterSiacoinElements, i.Parent) + } + + s := t.DialStream(ctx) + defer s.Close() + + req := rhp4.RPCFormContractRequest{ + Prices: p, + Contract: params, + Basis: basis, + MinerFee: formationTxn.MinerFee, + RenterInputs: renterSiacoinElements, + RenterParents: formationSet, + } + if err := rhp4.WriteRequest(s, rhp4.RPCFormContractID, &req); err != nil { + return RPCFormContractResult{}, fmt.Errorf("failed to write request: %w", err) + } + + var hostInputsResp rhp4.RPCFormContractResponse + if err := rhp4.ReadResponse(s, &hostInputsResp); err != nil { + return RPCFormContractResult{}, 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 RPCFormContractResult{}, 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 after the host inputs have been added + signer.SignV2Inputs(&formationTxn, toSign) + formationSigHash := cs.ContractSigHash(fc) + formationTxn.FileContracts[0].RenterSignature = signer.SignHash(formationSigHash) + + renterPolicyResp := rhp4.RPCFormContractSecondResponse{ + RenterContractSignature: formationTxn.FileContracts[0].RenterSignature, + } + 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 RPCFormContractResult{}, 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 RPCFormContractResult{}, fmt.Errorf("failed to read final response: %w", err) + } + + if len(hostTransactionSetResp.TransactionSet) == 0 { + return RPCFormContractResult{}, fmt.Errorf("expected at least one host transaction") + } + hostFormationTxn := hostTransactionSetResp.TransactionSet[len(hostTransactionSetResp.TransactionSet)-1] + if len(hostFormationTxn.FileContracts) != 1 { + return RPCFormContractResult{}, fmt.Errorf("expected exactly one contract") + } + + // check for no funny business + formationTxnID := formationTxn.ID() + hostFormationTxnID := hostFormationTxn.ID() + if formationTxnID != hostFormationTxnID { + return RPCFormContractResult{}, errors.New("transaction ID mismatch") + } + + // validate the host signature + fc.HostSignature = hostFormationTxn.FileContracts[0].HostSignature + if !fc.HostPublicKey.VerifyHash(formationSigHash, fc.HostSignature) { + return RPCFormContractResult{}, errors.New("invalid host signature") + } + + return RPCFormContractResult{ + Contract: ContractRevision{ + ID: formationTxn.V2FileContractID(formationTxnID, 0), + Revision: fc, + }, + FormationSet: TransactionSet{ + Basis: hostTransactionSetResp.Basis, + Transactions: hostTransactionSetResp.TransactionSet, + }, + Cost: renterCost, + Usage: usage, + }, nil +} + +// RPCRenewContract renews a contract with a host. +func RPCRenewContract(ctx context.Context, t TransportClient, tp TxPool, signer FormContractSigner, cs consensus.State, p rhp4.HostPrices, existing types.V2FileContract, params rhp4.RPCRenewContractParams) (RPCRenewContractResult, error) { + renewal, usage := rhp4.RenewContract(existing, p, params) + renewalTxn := types.V2Transaction{ + MinerFee: tp.RecommendedFee().Mul64(1000), + 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(params.ContractID), + }, + }, + Resolution: &renewal, + }, + }, + } + + renterCost, hostCost := rhp4.RenewalCost(cs, p, renewal, renewalTxn.MinerFee) + req := rhp4.RPCRenewContractRequest{ + Prices: p, + Renewal: params, + MinerFee: renewalTxn.MinerFee, + } + + basis, toSign, err := signer.FundV2Transaction(&renewalTxn, renterCost) + if err != nil { + return RPCRenewContractResult{}, fmt.Errorf("failed to fund transaction: %w", err) + } + + req.Basis, req.RenterParents, err = tp.V2TransactionSet(basis, renewalTxn) + if err != nil { + return RPCRenewContractResult{}, fmt.Errorf("failed to get transaction set: %w", err) + } + for _, si := range renewalTxn.SiacoinInputs { + req.RenterInputs = append(req.RenterInputs, si.Parent) + } + req.RenterParents = req.RenterParents[:len(req.RenterParents)-1] // last transaction is the renewal + + sigHash := req.ChallengeSigHash(existing.RevisionNumber) + req.ChallengeSignature = signer.SignHash(sigHash) + + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCRenewContractID, &req); err != nil { + return RPCRenewContractResult{}, fmt.Errorf("failed to write request: %w", err) + } + + var hostInputsResp rhp4.RPCRenewContractResponse + if err := rhp4.ReadResponse(s, &hostInputsResp); err != nil { + return RPCRenewContractResult{}, 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) + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, si) + } + + // verify the host added enough inputs + if n := hostInputSum.Cmp(hostCost); n < 0 { + return RPCRenewContractResult{}, fmt.Errorf("expected host to fund %v, got %v", hostCost, hostInputSum) + } else if n > 0 { + // add change output + renewalTxn.SiacoinOutputs = append(renewalTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: existing.HostOutput.Address, + Value: hostInputSum.Sub(hostCost), + }) + } + + // sign the renter inputs after the host inputs have been added + signer.SignV2Inputs(&renewalTxn, toSign) + // sign the renewal + renewalSigHash := cs.RenewalSigHash(renewal) + renewal.RenterSignature = signer.SignHash(renewalSigHash) + + // send the renter signatures + renterPolicyResp := rhp4.RPCRenewContractSecondResponse{ + RenterRenewalSignature: renewal.RenterSignature, + } + for _, si := range renewalTxn.SiacoinInputs[:len(req.RenterInputs)] { + renterPolicyResp.RenterSatisfiedPolicies = append(renterPolicyResp.RenterSatisfiedPolicies, si.SatisfiedPolicy) + } + if err := rhp4.WriteResponse(s, &renterPolicyResp); err != nil { + return RPCRenewContractResult{}, 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 RPCRenewContractResult{}, fmt.Errorf("failed to read final response: %w", err) + } + + if len(hostTransactionSetResp.TransactionSet) == 0 { + return RPCRenewContractResult{}, fmt.Errorf("expected at least one host transaction") + } + hostRenewalTxn := hostTransactionSetResp.TransactionSet[len(hostTransactionSetResp.TransactionSet)-1] + if len(hostRenewalTxn.FileContractResolutions) != 1 { + return RPCRenewContractResult{}, fmt.Errorf("expected exactly one resolution") + } + + hostRenewal, ok := hostRenewalTxn.FileContractResolutions[0].Resolution.(*types.V2FileContractRenewal) + if !ok { + return RPCRenewContractResult{}, fmt.Errorf("expected renewal resolution") + } + + // validate the host signature + if !existing.HostPublicKey.VerifyHash(renewalSigHash, hostRenewal.HostSignature) { + return RPCRenewContractResult{}, errors.New("invalid host signature") + } + return RPCRenewContractResult{ + Contract: ContractRevision{ + ID: params.ContractID.V2RenewalID(), + Revision: renewal.NewContract, + }, + RenewalSet: TransactionSet{ + Basis: hostTransactionSetResp.Basis, + Transactions: hostTransactionSetResp.TransactionSet, + }, + Cost: renterCost, + Usage: usage, + }, nil +} + +// RPCRefreshContract refreshes a contract with a host. +func RPCRefreshContract(ctx context.Context, t TransportClient, tp TxPool, signer FormContractSigner, cs consensus.State, p rhp4.HostPrices, existing types.V2FileContract, params rhp4.RPCRefreshContractParams) (RPCRefreshContractResult, error) { + renewal, usage := rhp4.RefreshContract(existing, p, params) + renewalTxn := types.V2Transaction{ + MinerFee: tp.RecommendedFee().Mul64(1000), + 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(params.ContractID), + }, + }, + Resolution: &renewal, + }, + }, + } + + renterCost, hostCost := rhp4.RefreshCost(cs, p, renewal, renewalTxn.MinerFee) + req := rhp4.RPCRefreshContractRequest{ + Prices: p, + Refresh: params, + MinerFee: renewalTxn.MinerFee, + } + + basis, toSign, err := signer.FundV2Transaction(&renewalTxn, renterCost) + if err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to fund transaction: %w", err) + } + + req.Basis, req.RenterParents, err = tp.V2TransactionSet(basis, renewalTxn) + if err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to get transaction set: %w", err) + } + for _, si := range renewalTxn.SiacoinInputs { + req.RenterInputs = append(req.RenterInputs, si.Parent) + } + req.RenterParents = req.RenterParents[:len(req.RenterParents)-1] // last transaction is the renewal + + sigHash := req.ChallengeSigHash(existing.RevisionNumber) + req.ChallengeSignature = signer.SignHash(sigHash) + + s := t.DialStream(ctx) + defer s.Close() + + if err := rhp4.WriteRequest(s, rhp4.RPCRefreshContractID, &req); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to write request: %w", err) + } + + var hostInputsResp rhp4.RPCRefreshContractResponse + if err := rhp4.ReadResponse(s, &hostInputsResp); err != nil { + return RPCRefreshContractResult{}, 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) + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, si) + } + + // verify the host added enough inputs + if n := hostInputSum.Cmp(hostCost); n < 0 { + return RPCRefreshContractResult{}, fmt.Errorf("expected host to fund %v, got %v", hostCost, hostInputSum) + } else if n > 0 { + // add change output + renewalTxn.SiacoinOutputs = append(renewalTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: existing.HostOutput.Address, + Value: hostInputSum.Sub(hostCost), + }) + } + + // sign the renter inputs after adding the host inputs + signer.SignV2Inputs(&renewalTxn, toSign) + // sign the renewal + renewalSigHash := cs.RenewalSigHash(renewal) + renewal.RenterSignature = signer.SignHash(renewalSigHash) + + // send the renter signatures + renterPolicyResp := rhp4.RPCRefreshContractSecondResponse{ + RenterRenewalSignature: renewal.RenterSignature, + } + for _, si := range renewalTxn.SiacoinInputs[:len(req.RenterInputs)] { + renterPolicyResp.RenterSatisfiedPolicies = append(renterPolicyResp.RenterSatisfiedPolicies, si.SatisfiedPolicy) + } + if err := rhp4.WriteResponse(s, &renterPolicyResp); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to write signature response: %w", err) + } + + // read the finalized transaction set + var hostTransactionSetResp rhp4.RPCRefreshContractThirdResponse + if err := rhp4.ReadResponse(s, &hostTransactionSetResp); err != nil { + return RPCRefreshContractResult{}, fmt.Errorf("failed to read final response: %w", err) + } + + if len(hostTransactionSetResp.TransactionSet) == 0 { + return RPCRefreshContractResult{}, fmt.Errorf("expected at least one host transaction") + } + hostRenewalTxn := hostTransactionSetResp.TransactionSet[len(hostTransactionSetResp.TransactionSet)-1] + if len(hostRenewalTxn.FileContractResolutions) != 1 { + return RPCRefreshContractResult{}, fmt.Errorf("expected exactly one resolution") + } + + hostRenewal, ok := hostRenewalTxn.FileContractResolutions[0].Resolution.(*types.V2FileContractRenewal) + if !ok { + return RPCRefreshContractResult{}, fmt.Errorf("expected renewal resolution") + } + + // validate the host signature + if !existing.HostPublicKey.VerifyHash(renewalSigHash, hostRenewal.HostSignature) { + return RPCRefreshContractResult{}, errors.New("invalid host signature") + } + return RPCRefreshContractResult{ + Contract: ContractRevision{ + ID: params.ContractID.V2RenewalID(), + Revision: renewal.NewContract, + }, + RenewalSet: TransactionSet{ + Basis: hostTransactionSetResp.Basis, + Transactions: hostTransactionSetResp.TransactionSet, + }, + Cost: renterCost, + Usage: usage, + }, nil +} diff --git a/rhp/v4/rpc_test.go b/rhp/v4/rpc_test.go new file mode 100644 index 0000000..969fccf --- /dev/null +++ b/rhp/v4/rpc_test.go @@ -0,0 +1,1561 @@ +package rhp_test + +import ( + "bytes" + "context" + "io" + "net" + "reflect" + "strings" + "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/syncer" + "go.sia.tech/coreutils/testutil" + "go.sia.tech/coreutils/wallet" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + "lukechampine.com/frand" +) + +type readerLen struct { + r io.Reader + length int +} + +func NewReaderLen(buf []byte) rhp4.ReaderLen { + return &readerLen{r: bytes.NewReader(buf), length: len(buf)} +} + +func (r *readerLen) Len() (int, error) { + return r.length, nil +} + +func (r *readerLen) Read(p []byte) (int, error) { + return r.r.Read(p) +} + +type fundAndSign struct { + w *wallet.SingleAddressWallet + pk types.PrivateKey +} + +func (fs *fundAndSign) FundV2Transaction(txn *types.V2Transaction, amount types.Currency) (types.ChainIndex, []int, error) { + return fs.w.FundV2Transaction(txn, amount, true) +} +func (fs *fundAndSign) ReleaseInputs(txns []types.V2Transaction) { + fs.w.ReleaseInputs(nil, txns) +} + +func (fs *fundAndSign) SignV2Inputs(txn *types.V2Transaction, toSign []int) { + fs.w.SignV2Inputs(txn, toSign) +} +func (fs *fundAndSign) SignHash(h types.Hash256) types.Signature { + return fs.pk.SignHash(h) +} +func (fs *fundAndSign) PublicKey() types.PublicKey { + return fs.pk.PublicKey() +} +func (fs *fundAndSign) Address() types.Address { + return fs.w.Address() +} + +func testRenterHostPair(tb testing.TB, hostKey types.PrivateKey, cm rhp4.ChainManager, s rhp4.Syncer, w rhp4.Wallet, c rhp4.Contractor, sr rhp4.Settings, ss rhp4.Sectors, log *zap.Logger) rhp4.TransportClient { + rs := rhp4.NewServer(hostKey, cm, s, c, w, sr, ss, rhp4.WithContractProofWindowBuffer(10), rhp4.WithPriceTableValidity(2*time.Minute)) + hostAddr := testutil.ServeSiaMux(tb, rs, log.Named("siamux")) + + transport, err := rhp4.DialSiaMux(context.Background(), hostAddr, hostKey.PublicKey()) + if err != nil { + tb.Fatal(err) + } + tb.Cleanup(func() { transport.Close() }) + + return transport +} + +func startTestNode(tb testing.TB, n *consensus.Network, genesis types.Block) (*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) + + 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", + }) + 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) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), 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) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.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) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.FormationSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } +} + +func TestRPCRefresh(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + cm, s, w := startTestNode(t, n, genesis) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + fundAndSign := &fundAndSign{w, renterKey} + + formContractFundAccount := func(t *testing.T, renterAllowance, hostCollateral, accountBalance types.Currency) rhp4.ContractRevision { + t.Helper() + + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := result.Contract + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.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 + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountBalance}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + return revision + } + + t.Run("no allowance or collateral", func(t *testing.T) { + revision := formContractFundAccount(t, types.Siacoins(100), types.Siacoins(200), types.Siacoins(25)) + + // refresh the contract + _, err = rhp4.RPCRefreshContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRefreshContractParams{ + ContractID: revision.ID, + Allowance: types.ZeroCurrency, + Collateral: types.ZeroCurrency, + }) + if err == nil { + t.Fatal(err) + } else if !strings.Contains(err.Error(), "allowance must be greater than zero") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("valid refresh", func(t *testing.T) { + revision := formContractFundAccount(t, types.Siacoins(100), types.Siacoins(200), types.Siacoins(25)) + // refresh the contract + refreshResult, err := rhp4.RPCRefreshContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRefreshContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(10), + Collateral: types.Siacoins(20), + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(refreshResult.RenewalSet.Basis, refreshResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + }) +} + +func TestRPCRenew(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + cm, s, w := startTestNode(t, n, genesis) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + fundAndSign := &fundAndSign{w, renterKey} + + formContractFundAccount := func(t *testing.T, renterAllowance, hostCollateral, accountBalance types.Currency) rhp4.ContractRevision { + t.Helper() + + result, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := result.Contract + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(result.FormationSet.Basis, result.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 + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountBalance}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + return revision + } + + t.Run("same duration", func(t *testing.T) { + revision := formContractFundAccount(t, types.Siacoins(100), types.Siacoins(200), types.Siacoins(25)) + + // renew the contract + _, err = rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(150), + Collateral: types.Siacoins(300), + ProofHeight: revision.Revision.ProofHeight, + }) + if err == nil { + t.Fatal(err) + } else if !strings.Contains(err.Error(), "renewal proof height must be greater than existing proof height") { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("partial rollover", func(t *testing.T) { + revision := formContractFundAccount(t, types.Siacoins(100), types.Siacoins(200), types.Siacoins(25)) + + // renew the contract + renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(150), + Collateral: types.Siacoins(300), + ProofHeight: revision.Revision.ProofHeight + 10, + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + }) + + t.Run("full rollover", func(t *testing.T) { + revision := formContractFundAccount(t, types.Siacoins(100), types.Siacoins(200), types.Siacoins(25)) + + // renew the contract + renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(50), + Collateral: types.Siacoins(100), + ProofHeight: revision.Revision.ProofHeight + 10, + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.RenewalSet.Transactions); err != nil { + t.Fatal(err) + } else if !known { + t.Fatal("expected transaction set to be known") + } + }) + + t.Run("no rollover", func(t *testing.T) { + revision := formContractFundAccount(t, types.Siacoins(100), types.Siacoins(200), types.Siacoins(25)) + + // renew the contract + renewResult, err := rhp4.RPCRenewContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, revision.Revision, proto4.RPCRenewContractParams{ + ContractID: revision.ID, + Allowance: types.Siacoins(150), + Collateral: types.Siacoins(300), + ProofHeight: revision.Revision.ProofHeight + 10, + }) + if err != nil { + t.Fatal(err) + } + + // verify the transaction set is valid + if known, err := cm.AddV2PoolTransactions(renewResult.RenewalSet.Basis, renewResult.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) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract + + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revised := fundResult.Revision + + renterOutputValue := revision.Revision.RenterOutput.Value.Sub(accountFundAmount) + hostOutputValue := revision.Revision.HostOutput.Value.Add(accountFundAmount) + + // verify the contract was modified correctly + switch { + case fundResult.Balances[0].Account != account: + t.Fatalf("expected %v, got %v", account, fundResult.Balances[0].Account) + case fundResult.Balances[0].Balance != accountFundAmount: + t.Fatalf("expected %v, got %v", accountFundAmount, fundResult.Balances[0].Balance) + case !fundResult.Usage.RenterCost().Equals(accountFundAmount): + t.Fatalf("expected %v, got %v", accountFundAmount, fundResult.Usage.RenterCost()) + 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(context.Background(), 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) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract + + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + 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 + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(data), 5) + if err != nil { + t.Fatal(err) + } + + // verify the sector root + var sector [proto4.SectorSize]byte + copy(sector[:], data) + if writeResult.Root != proto4.SectorRoot(§or) { + t.Fatal("root mismatch") + } + + // read the sector back + buf := bytes.NewBuffer(nil) + _, err = rhp4.RPCReadSector(context.Background(), transport, settings.Prices, token, buf, writeResult.Root, 0, 64) + if err != nil { + t.Fatal(err) + } else if !bytes.Equal(buf.Bytes(), data[:64]) { + t.Fatal("data mismatch") + } +} + +func TestAppendSectors(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract + + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + tokenSigHash := token.SigHash() + token.Signature = renterKey.SignHash(tokenSigHash) + + // store random sectors + roots := make([]types.Hash256, 0, 10) + for i := 0; i < 10; i++ { + var sector [proto4.SectorSize]byte + frand.Read(sector[:]) + root := proto4.SectorRoot(§or) + + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(sector[:]), 5) + if err != nil { + t.Fatal(err) + } else if writeResult.Root != root { + t.Fatal("root mismatch") + } + roots = append(roots, root) + } + + // corrupt a random root + excludedIndex := frand.Intn(len(roots)) + roots[excludedIndex] = frand.Entropy256() + + // append the sectors to the contract + appendResult, err := rhp4.RPCAppendSectors(context.Background(), transport, cs, settings.Prices, renterKey, revision, roots) + if err != nil { + t.Fatal(err) + } else if len(appendResult.Sectors) != len(roots)-1 { + t.Fatalf("expected %v, got %v", len(roots)-1, len(appendResult.Sectors)) + } + roots = append(roots[:excludedIndex], roots[excludedIndex+1:]...) + if appendResult.Revision.FileMerkleRoot != proto4.MetaRoot(roots) { + t.Fatal("root mismatch") + } + + // read the sectors back + buf := bytes.NewBuffer(make([]byte, 0, proto4.SectorSize)) + for _, root := range roots { + buf.Reset() + + _, err = rhp4.RPCReadSector(context.Background(), transport, settings.Prices, token, buf, root, 0, proto4.SectorSize) + if err != nil { + t.Fatal(err) + } else if proto4.SectorRoot((*[proto4.SectorSize]byte)(buf.Bytes())) != root { + t.Fatal("data mismatch") + } + } +} + +func TestVerifySector(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract + + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + 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 + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(data), 5) + if err != nil { + t.Fatal(err) + } + + // verify the sector root + var sector [proto4.SectorSize]byte + copy(sector[:], data) + if writeResult.Root != proto4.SectorRoot(§or) { + t.Fatal("root mismatch") + } + + // verify the host is storing the sector + _, err = rhp4.RPCVerifySector(context.Background(), transport, settings.Prices, token, writeResult.Root) + if err != nil { + t.Fatal(err) + } +} + +func TestRPCFreeSectors(t *testing.T) { + log := zaptest.NewLogger(t) + n, genesis := testutil.V2Network() + hostKey, renterKey := types.GeneratePrivateKey(), types.GeneratePrivateKey() + + cm, s, w := startTestNode(t, n, genesis) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract + + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + token := proto4.AccountToken{ + Account: account, + ValidUntil: time.Now().Add(time.Hour), + } + tokenSigHash := token.SigHash() + token.Signature = renterKey.SignHash(tokenSigHash) + + roots := make([]types.Hash256, 10) + for i := range roots { + // store random sectors on the host + data := frand.Bytes(1024) + + // store the sector + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(data), 5) + if err != nil { + t.Fatal(err) + } + roots[i] = writeResult.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 + appendResult, err := rhp4.RPCAppendSectors(context.Background(), transport, cs, settings.Prices, renterKey, revision, roots) + if err != nil { + t.Fatal(err) + } + assertRevision(t, appendResult.Revision, roots) + revision.Revision = appendResult.Revision + + // randomly remove half the sectors + indices := make([]uint64, len(roots)/2) + for i, n := range frand.Perm(len(roots))[:len(roots)/2] { + indices[i] = uint64(n) + } + newRoots := append([]types.Hash256(nil), roots...) + for i, n := range indices { + newRoots[n] = newRoots[len(newRoots)-i-1] + } + newRoots = newRoots[:len(newRoots)-len(indices)] + + removeResult, err := rhp4.RPCFreeSectors(context.Background(), transport, cs, settings.Prices, renterKey, revision, indices) + if err != nil { + t.Fatal(err) + } + assertRevision(t, removeResult.Revision, newRoots) +} + +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) + + // fund the wallet + mineAndSync(t, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + t.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + t.Fatal(err) + } + revision := formResult.Contract + + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + t.Fatal(err) + } + revision.Revision = fundResult.Revision + + 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() + + rootsResult, err := rhp4.RPCSectorRoots(context.Background(), 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 rootsResult.Roots { + if roots[i] != expected[i] { + t.Fatalf("expected %v, got %v", expected[i], roots[i]) + } + } + revision.Revision = rootsResult.Revision + } + + for i := 0; i < cap(roots); i++ { + // store random sectors on the host + data := frand.Bytes(1024) + + // store the sector + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(data), 5) + if err != nil { + t.Fatal(err) + } + roots = append(roots, writeResult.Root) + + appendResult, err := rhp4.RPCAppendSectors(context.Background(), transport, cs, settings.Prices, renterKey, revision, []types.Hash256{writeResult.Root}) + if err != nil { + t.Fatal(err) + } + revision.Revision = appendResult.Revision + 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) + + // fund the wallet + mineAndSync(b, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + b.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + b.Fatal(err) + } + revision := formResult.Contract + + // fund an account + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + b.Fatal(err) + } + revision.Revision = fundResult.Revision + + 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(context.Background(), transport, settings.Prices, token, NewReaderLen(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) + + // fund the wallet + mineAndSync(b, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + b.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + b.Fatal(err) + } + revision := formResult.Contract + + // fund an account + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + b.Fatal(err) + } + revision.Revision = fundResult.Revision + + 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 + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(sectors[i][:]), 5) + if err != nil { + b.Fatal(err) + } + roots = append(roots, writeResult.Root) + } + + b.ResetTimer() + b.ReportAllocs() + b.SetBytes(proto4.SectorSize) + + buf := bytes.NewBuffer(make([]byte, 0, proto4.SectorSize)) + for i := 0; i < b.N; i++ { + buf.Reset() + // store the sector + _, err = rhp4.RPCReadSector(context.Background(), transport, settings.Prices, token, buf, roots[i], 0, proto4.SectorSize) + if err != nil { + b.Fatal(err) + } else if !bytes.Equal(buf.Bytes(), 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) + + // fund the wallet + mineAndSync(b, cm, w.Address(), int(n.MaturityDelay+20), w) + + sr := testutil.NewEphemeralSettingsReporter() + sr.Update(proto4.HostSettings{ + Release: "test", + AcceptingContracts: true, + WalletAddress: w.Address(), + MaxCollateral: types.Siacoins(10000), + MaxContractDuration: 1000, + MaxSectorDuration: 3 * 144, + MaxSectorBatchSize: 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(context.Background(), transport) + if err != nil { + b.Fatal(err) + } + + fundAndSign := &fundAndSign{w, renterKey} + renterAllowance, hostCollateral := types.Siacoins(100), types.Siacoins(200) + formResult, err := rhp4.RPCFormContract(context.Background(), transport, cm, fundAndSign, cm.TipState(), settings.Prices, hostKey.PublicKey(), settings.WalletAddress, proto4.RPCFormContractParams{ + RenterPublicKey: renterKey.PublicKey(), + RenterAddress: w.Address(), + Allowance: renterAllowance, + Collateral: hostCollateral, + ProofHeight: cm.Tip().Height + 50, + }) + if err != nil { + b.Fatal(err) + } + revision := formResult.Contract + + // fund an account + cs := cm.TipState() + account := proto4.Account(renterKey.PublicKey()) + accountFundAmount := types.Siacoins(25) + fundResult, err := rhp4.RPCFundAccounts(context.Background(), transport, cs, renterKey, revision, []proto4.AccountDeposit{ + {Account: account, Amount: accountFundAmount}, + }) + if err != nil { + b.Fatal(err) + } + revision.Revision = fundResult.Revision + + 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) + roots = append(roots, proto4.SectorRoot(§or)) + } + + b.ResetTimer() + b.ReportAllocs() + b.SetBytes(proto4.SectorSize) + + var wg sync.WaitGroup + for i := 0; i < b.N; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + writeResult, err := rhp4.RPCWriteSector(context.Background(), transport, settings.Prices, token, NewReaderLen(sectors[i][:]), 5) + if err != nil { + b.Error(err) + } else if writeResult.Root != roots[i] { + b.Errorf("expected %v, got %v", roots[i], writeResult.Root) + } + }(i) + } + + wg.Wait() + + appendResult, err := rhp4.RPCAppendSectors(context.Background(), transport, cs, settings.Prices, renterKey, revision, roots) + if err != nil { + b.Fatal(err) + } else if appendResult.Revision.Filesize != uint64(b.N)*proto4.SectorSize { + b.Fatalf("expected %v sectors, got %v", b.N, appendResult.Revision.Filesize/proto4.SectorSize) + } +} diff --git a/rhp/v4/server.go b/rhp/v4/server.go new file mode 100644 index 0000000..fb41573 --- /dev/null +++ b/rhp/v4/server.go @@ -0,0 +1,1132 @@ +package rhp + +import ( + "bytes" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "time" + + "go.sia.tech/core/consensus" + rhp4 "go.sia.tech/core/rhp/v4" + "go.sia.tech/core/types" + "go.sia.tech/coreutils/wallet" + "go.uber.org/zap" + "lukechampine.com/frand" +) + +const ( + sectorsPerTiB = (1 << 40) / (1 << 22) + memoryPer1TiB = sectorsPerTiB * 32 + + sectorsPer10TiB = 10 * sectorsPerTiB + memoryPer10TiB = sectorsPer10TiB * 32 + + sectorsPer100TiB = 100 * sectorsPerTiB + memoryPer100TiB = sectorsPer100TiB * 32 +) + +var protocolVersion = [3]byte{4, 0, 0} + +type ( + // A TransportMux is a generic multiplexer for incoming streams. + TransportMux 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 + + // UpdateV2TransactionSet updates the basis of a transaction set from "from" to "to". + // If from and to are equal, the transaction set is returned as-is. + // Any transactions that were confirmed are removed from the set. + // Any ephemeral state elements that were created by an update are updated. + // + // If it is undesirable to modify the transaction set, deep-copy it + // before calling this method. + UpdateV2TransactionSet(txns []types.V2Transaction, from, to types.ChainIndex) ([]types.V2Transaction, 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 + + // FundV2Transaction 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 Sectors is an interface for reading and writing sectors. + Sectors interface { + // HasSector returns true if the sector is stored. + HasSector(root types.Hash256) (bool, error) + // ReadSector retrieves a sector by its root + ReadSector(root types.Hash256) (*[rhp4.SectorSize]byte, error) + // StoreSector writes a sector to disk + 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, rhp4.Usage) error + // RenewV2Contract finalizes an existing contract and adds its renewal. + RenewV2Contract(TransactionSet, rhp4.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 rhp4.Usage) error + // V2FileContractElement returns the contract state element for the given + // contract ID. + V2FileContractElement(types.FileContractID) (types.ChainIndex, types.V2FileContractElement, error) + + // AccountBalance returns the balance of an account. + AccountBalance(rhp4.Account) (types.Currency, error) + // CreditAccountsWithContract atomically revises a contract and credits the account. + CreditAccountsWithContract([]rhp4.AccountDeposit, types.FileContractID, types.V2FileContract, rhp4.Usage) ([]types.Currency, error) + // DebitAccount debits an account. + DebitAccount(rhp4.Account, rhp4.Usage) error + } + + // Settings reports the host's current settings. + Settings interface { + RHP4Settings() rhp4.HostSettings + } + + // A Server handles incoming RHP4 RPC. + Server struct { + hostKey types.PrivateKey + priceTableValidity time.Duration + contractProofWindowBuffer uint64 + + chain ChainManager + syncer Syncer + wallet Wallet + sectors Sectors + contractor Contractor + settings Settings + } +) + +func (s *Server) lockContractForRevision(contractID types.FileContractID) (rev RevisionState, unlock func(), _ error) { + rev, unlock, err := s.contractor.LockV2Contract(contractID) + if err != nil { + return RevisionState{}, nil, fmt.Errorf("failed to lock contract: %w", err) + } else if rev.Revision.ProofHeight <= s.chain.Tip().Height+s.contractProofWindowBuffer { + unlock() + return RevisionState{}, nil, errorBadRequest("contract too close to proof window") + } else if rev.Revision.RevisionNumber >= types.MaxRevisionNumber { + unlock() + return RevisionState{}, nil, errorBadRequest("contract is locked for revision") + } + return rev, unlock, nil +} + +func (s *Server) handleRPCSettings(stream net.Conn) error { + settings := s.settings.RHP4Settings() + settings.ProtocolVersion = protocolVersion + settings.Prices.TipHeight = s.chain.Tip().Height + 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, log *zap.Logger) error { + st := time.Now() + lap := func(context string) { + log.Debug(context, zap.Duration("elapsed", time.Since(st))) + st = time.Now() + } + var req rhp4.RPCReadSectorRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + lap("read request") + + if err := req.Validate(s.hostKey.PublicKey()); err != nil { + return errorBadRequest("request invalid: %v", err) + } + prices, token := req.Prices, req.Token + lap("validate request") + + if exists, err := s.sectors.HasSector(req.Root); err != nil { + return fmt.Errorf("failed to check sector: %w", err) + } else if !exists { + return rhp4.ErrSectorNotFound + } + lap("check sector") + + err := s.contractor.DebitAccount(token.Account, prices.RPCReadSectorCost(req.Length)) + if err != nil { + return fmt.Errorf("failed to debit account: %w", err) + } + lap("debit account") + + sector, err := s.sectors.ReadSector(req.Root) + if err != nil { + return fmt.Errorf("failed to read sector: %w", err) + } + lap("read sector") + + segment := sector[req.Offset : req.Offset+req.Length] + start := req.Offset / rhp4.LeafSize + end := (req.Offset + req.Length + rhp4.LeafSize - 1) / rhp4.LeafSize + proof := rhp4.BuildSectorProof(sector, start, end) + lap("build proof") + + return rhp4.WriteResponse(stream, &rhp4.RPCReadSectorResponse{ + Sector: segment, + Proof: proof, + }) +} + +func (s *Server) handleRPCWriteSector(stream net.Conn) error { + var req rhp4.RPCWriteSectorStreamingRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + settings := s.settings.RHP4Settings() + if err := req.Validate(s.hostKey.PublicKey(), settings.MaxSectorDuration); err != nil { + return errorBadRequest("request invalid: %v", err) + } + prices := req.Prices + + var sector [rhp4.SectorSize]byte + sr := io.LimitReader(stream, int64(req.DataLength)) + if req.DataLength < rhp4.SectorSize { + // if the data is less than a full sector, the reader needs to be padded + // with zeros to calculate the sector root + sr = io.MultiReader(sr, bytes.NewReader(sector[req.DataLength:])) + } + + buf := bytes.NewBuffer(sector[:0]) + root, err := rhp4.ReaderRoot(io.TeeReader(sr, buf)) + if err != nil { + return errorDecodingError("failed to read sector data: %v", err) + } + + usage := prices.RPCWriteSectorCost(req.DataLength, req.Duration) + if err = s.contractor.DebitAccount(req.Token.Account, usage); err != nil { + return fmt.Errorf("failed to debit account: %w", err) + } + + if err := s.sectors.StoreSector(root, §or, req.Duration); err != nil { + return fmt.Errorf("failed to store sector: %w", err) + } + return rhp4.WriteResponse(stream, &rhp4.RPCWriteSectorResponse{ + Root: root, + }) +} + +func (s *Server) handleRPCFreeSectors(stream net.Conn) error { + var req rhp4.RPCFreeSectorsRequest + 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() + + if !req.ValidChallengeSignature(state.Revision) { + return errorBadRequest("invalid challenge signature") + } + + fc := state.Revision + + settings := s.settings.RHP4Settings() + if err := req.Validate(s.hostKey.PublicKey(), fc, settings.MaxSectorBatchSize); err != nil { + return errorBadRequest("request invalid: %v", err) + } + prices := req.Prices + + // validate that all indices are within the expected range + for _, i := range req.Indices { + if i >= uint64(len(state.Roots)) { + return errorBadRequest("index %v exceeds sector count %v", i, len(state.Roots)) + } + } + + oldSubtreeHashes, oldLeafHashes := rhp4.BuildFreeSectorsProof(state.Roots, req.Indices) + + // modify the sector roots + // + // NOTE: must match the behavior of BuildFreeSectorsProof + for i, n := range req.Indices { + state.Roots[n] = state.Roots[len(state.Roots)-i-1] + } + state.Roots = state.Roots[:len(state.Roots)-len(req.Indices)] + resp := rhp4.RPCFreeSectorsResponse{ + OldSubtreeHashes: oldSubtreeHashes, + OldLeafHashes: oldLeafHashes, + NewMerkleRoot: rhp4.MetaRoot(state.Roots), + } + if err := rhp4.WriteResponse(stream, &resp); err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + var renterSigResponse rhp4.RPCFreeSectorsSecondResponse + if err := rhp4.ReadResponse(stream, &renterSigResponse); err != nil { + return errorDecodingError("failed to read renter signature response: %v", err) + } + + revision, usage, err := rhp4.ReviseForFreeSectors(fc, prices, resp.NewMerkleRoot, len(req.Indices)) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + cs := s.chain.TipState() + sigHash := cs.ContractSigHash(revision) + + if !fc.RenterPublicKey.VerifyHash(sigHash, renterSigResponse.RenterSignature) { + return rhp4.ErrInvalidSignature + } + revision.RenterSignature = renterSigResponse.RenterSignature + revision.HostSignature = s.hostKey.SignHash(sigHash) + + err = s.contractor.ReviseV2Contract(req.ContractID, revision, state.Roots, usage) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + return rhp4.WriteResponse(stream, &rhp4.RPCFreeSectorsThirdResponse{ + HostSignature: revision.HostSignature, + }) +} + +func (s *Server) handleRPCAppendSectors(stream net.Conn) error { + var req rhp4.RPCAppendSectorsRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } + + settings := s.settings.RHP4Settings() + if err := req.Validate(s.hostKey.PublicKey(), settings.MaxSectorBatchSize); err != nil { + return errorBadRequest("request invalid: %v", 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 + roots := state.Roots + accepted := make([]bool, len(req.Sectors)) + var appended uint64 + for i, root := range req.Sectors { + if ok, err := s.sectors.HasSector(root); err != nil { + return fmt.Errorf("failed to check sector: %w", err) + } else if !ok { + continue + } + accepted[i] = true + roots = append(roots, root) + appended++ + } + + subtreeRoots, newRoot := rhp4.BuildAppendProof(state.Roots, roots[len(state.Roots):]) + resp := rhp4.RPCAppendSectorsResponse{ + Accepted: accepted, + SubtreeRoots: subtreeRoots, + NewMerkleRoot: newRoot, + } + if err := rhp4.WriteResponse(stream, &resp); err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + + revision, usage, err := rhp4.ReviseForAppendSectors(fc, req.Prices, newRoot, appended) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + sigHash := cs.ContractSigHash(revision) + + var renterSigResponse rhp4.RPCAppendSectorsSecondResponse + if err := rhp4.ReadResponse(stream, &renterSigResponse); err != nil { + return errorDecodingError("failed to read renter signature response: %v", err) + } else if !fc.RenterPublicKey.VerifyHash(sigHash, renterSigResponse.RenterSignature) { + return rhp4.ErrInvalidSignature + } + + revision.RenterSignature = renterSigResponse.RenterSignature + revision.HostSignature = s.hostKey.SignHash(sigHash) + + err = s.contractor.ReviseV2Contract(req.ContractID, revision, roots, usage) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + return rhp4.WriteResponse(stream, &rhp4.RPCAppendSectorsThirdResponse{ + 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) + } + + revision, usage, err := rhp4.ReviseForFundAccounts(state.Revision, totalDeposits) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + + sigHash := s.chain.TipState().ContractSigHash(revision) + if !revision.RenterPublicKey.VerifyHash(sigHash, req.RenterSignature) { + return rhp4.ErrInvalidSignature + } + revision.HostSignature = s.hostKey.SignHash(sigHash) + + balances, err := s.contractor.CreditAccountsWithContract(req.Deposits, req.ContractID, revision, usage) + if err != nil { + return fmt.Errorf("failed to credit account: %w", err) + } + + return rhp4.WriteResponse(stream, &rhp4.RPCFundAccountsResponse{ + Balances: balances, + HostSignature: revision.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) + } + 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) + } + + state, unlock, err := s.lockContractForRevision(req.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract: %w", err) + } + defer unlock() + + // validate the request fields + settings := s.settings.RHP4Settings() + if err := req.Validate(s.hostKey.PublicKey(), state.Revision, settings.MaxSectorBatchSize); err != nil { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) + } + prices := req.Prices + + // update the revision + revision, usage, err := rhp4.ReviseForSectorRoots(state.Revision, prices, req.Length) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + + // validate the renter's signature + cs := s.chain.TipState() + sigHash := cs.ContractSigHash(revision) + if !state.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) + if err != nil { + return fmt.Errorf("failed to revise contract: %w", err) + } + + roots := state.Roots[req.Offset : req.Offset+req.Length] + proof := rhp4.BuildSectorRootsProof(state.Roots, req.Offset, req.Offset+req.Length) + + // send the response + return rhp4.WriteResponse(stream, &rhp4.RPCSectorRootsResponse{ + Proof: proof, + Roots: 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) + } + + ourKey := s.hostKey.PublicKey() + settings := s.settings.RHP4Settings() + tip := s.chain.Tip() + if err := req.Validate(ourKey, tip, settings.MaxCollateral, settings.MaxContractDuration); err != nil { + return err + } + prices := req.Prices + + fc, usage := rhp4.NewContract(prices, req.Contract, ourKey, settings.WalletAddress) + 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() + renterCost, hostCost := rhp4.ContractCost(cs, prices, formationTxn.FileContracts[0], formationTxn.MinerFee) + // validate the renter added enough inputs + if renterInputs.Cmp(renterCost) < 0 { + return errorBadRequest("renter funding %v is less than required funding %v", renterInputs, renterCost) + } else if !renterInputs.Equals(renterCost) { + // if the renter added too much, add a change output + formationTxn.SiacoinOutputs = append(formationTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: req.Contract.RenterAddress, + Value: renterInputs.Sub(renterCost), + }) + } + + // fund the host collateral + basis, toSign, err := s.wallet.FundV2Transaction(&formationTxn, hostCost, 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) + } + + // update renter input basis to reflect our funding basis + if basis != req.Basis { + hostInputs := formationTxn.SiacoinInputs[len(formationTxn.SiacoinInputs)-len(req.RenterInputs)] + formationTxn.SiacoinInputs = formationTxn.SiacoinInputs[:len(formationTxn.SiacoinInputs)-len(req.RenterInputs)] + txnset, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{formationTxn}, req.Basis, basis) + if err != nil { + return errorBadRequest("failed to update renter inputs from %q to %q: %v", req.Basis, basis, err) + } + formationTxn = txnset[0] + formationTxn.SiacoinInputs = append(formationTxn.SiacoinInputs, hostInputs) + } + + // 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)) + } + + // validate the renter's contract signature + formationSigHash := cs.ContractSigHash(formationTxn.FileContracts[0]) + if !req.Contract.RenterPublicKey.VerifyHash(formationSigHash, renterSigResp.RenterContractSignature) { + return rhp4.ErrInvalidSignature + } + formationTxn.FileContracts[0].RenterSignature = renterSigResp.RenterContractSignature + + // add the renter signatures to the transaction + 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(formationSigHash) + + // 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{ + Transactions: formationSet, + 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.RPCFormContractThirdResponse{ + Basis: basis, + TransactionSet: formationSet, + }) +} + +func (s *Server) handleRPCRefreshContract(stream net.Conn) error { + var req rhp4.RPCRefreshContractRequest + 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 existing contract + state, unlock, err := s.lockContractForRevision(req.Refresh.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract %q: %w", req.Refresh.ContractID, err) + } + defer unlock() + + // validate challenge signature + existing := state.Revision + if !req.ValidChallengeSignature(existing) { + return errorBadRequest("invalid challenge signature") + } + + // validate the request + settings := s.settings.RHP4Settings() + if err := req.Validate(s.hostKey.PublicKey(), state.Revision.ExpirationHeight, settings.MaxCollateral); err != nil { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) + } + + cs := s.chain.TipState() + renewal, usage := rhp4.RefreshContract(existing, prices, req.Refresh) + renterCost, hostCost := rhp4.RefreshCost(cs, prices, renewal, req.MinerFee) + renewalTxn := types.V2Transaction{ + MinerFee: req.MinerFee, + } + + // add the renter inputs + var renterInputSum types.Currency + for _, si := range req.RenterInputs { + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: si, + }) + renterInputSum = renterInputSum.Add(si.SiacoinOutput.Value) + } + + if n := renterInputSum.Cmp(renterCost); n < 0 { + return errorBadRequest("expected renter to fund %v, got %v", renterInputSum, renterCost) + } else if n > 0 { + // if the renter added too much, add a change output + renewalTxn.SiacoinOutputs = append(renewalTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: renewal.NewContract.RenterOutput.Address, + Value: renterInputSum.Sub(renterCost), + }) + } + + elementBasis, fce, err := s.contractor.V2FileContractElement(req.Refresh.ContractID) + if err != nil { + return fmt.Errorf("failed to get contract element: %w", err) + } + + basis, toSign, err := s.wallet.FundV2Transaction(&renewalTxn, hostCost, true) + if errors.Is(err, wallet.ErrNotEnoughFunds) { + return rhp4.ErrHostFundError + } else if err != nil { + return fmt.Errorf("failed to fund transaction: %w", err) + } + + // update renter inputs to reflect our chain state + if basis != req.Basis { + hostInputs := renewalTxn.SiacoinInputs[len(renewalTxn.SiacoinInputs)-len(req.RenterInputs):] + renewalTxn.SiacoinInputs = renewalTxn.SiacoinInputs[:len(renewalTxn.SiacoinInputs)-len(req.RenterInputs)] + updated, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{renewalTxn}, req.Basis, basis) + if err != nil { + return errorBadRequest("failed to update renter inputs from %q to %q: %v", req.Basis, basis, err) + } + renewalTxn = updated[0] + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, hostInputs...) + } + + if elementBasis != basis { + tempTxn := types.V2Transaction{ + FileContractResolutions: []types.V2FileContractResolution{ + {Parent: fce, Resolution: &renewal}, + }, + } + updated, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{tempTxn}, elementBasis, basis) + if err != nil { + return fmt.Errorf("failed to update contract element: %w", err) + } + fce = updated[0].FileContractResolutions[0].Parent + } + renewalTxn.FileContractResolutions = []types.V2FileContractResolution{ + {Parent: fce, Resolution: &renewal}, + } + s.wallet.SignV2Inputs(&renewalTxn, toSign) + // send the host inputs to the renter + hostInputsResp := rhp4.RPCRefreshContractResponse{ + 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.RPCRefreshContractSecondResponse + 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)) + } + + // validate the renter's signature + renewalSigHash := cs.RenewalSigHash(renewal) + if !existing.RenterPublicKey.VerifyHash(renewalSigHash, renterSigResp.RenterRenewalSignature) { + return rhp4.ErrInvalidSignature + } + renewal.RenterSignature = renterSigResp.RenterRenewalSignature + + // apply the renter's signatures + for i, policy := range renterSigResp.RenterSatisfiedPolicies { + renewalTxn.SiacoinInputs[i].SatisfiedPolicy = policy + } + renewal.HostSignature = s.hostKey.SignHash(renewalSigHash) + + // 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 renewal parents to transaction pool: %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 renewal set: %v", err) + } + // broadcast the transaction set + s.syncer.BroadcastV2TransactionSet(basis, renewalSet) + + // add the contract to the contractor + err = s.contractor.RenewV2Contract(TransactionSet{ + Transactions: 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.RPCRefreshContractThirdResponse{ + Basis: basis, + TransactionSet: renewalSet, + }) +} + +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 existing contract + state, unlock, err := s.lockContractForRevision(req.Renewal.ContractID) + if err != nil { + return fmt.Errorf("failed to lock contract %q: %w", req.Renewal.ContractID, err) + } + defer unlock() + + settings := s.settings.RHP4Settings() + tip := s.chain.Tip() + + // validate the request + if err := req.Validate(s.hostKey.PublicKey(), tip, state.Revision.ProofHeight, settings.MaxCollateral, settings.MaxContractDuration); err != nil { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) + } + + // validate challenge signature + existing := state.Revision + if !req.ValidChallengeSignature(existing) { + return errorBadRequest("invalid challenge signature") + } + + cs := s.chain.TipState() + renewal, usage := rhp4.RenewContract(existing, prices, req.Renewal) + renterCost, hostCost := rhp4.RenewalCost(cs, prices, renewal, req.MinerFee) + renewalTxn := types.V2Transaction{ + MinerFee: req.MinerFee, + } + + // add the renter inputs + var renterInputSum types.Currency + for _, si := range req.RenterInputs { + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, types.V2SiacoinInput{ + Parent: si, + }) + renterInputSum = renterInputSum.Add(si.SiacoinOutput.Value) + } + + if n := renterInputSum.Cmp(renterCost); n < 0 { + return errorBadRequest("expected renter to fund %v, got %v", renterInputSum, renterCost) + } else if n > 0 { + // if the renter added too much, add a change output + renewalTxn.SiacoinOutputs = append(renewalTxn.SiacoinOutputs, types.SiacoinOutput{ + Address: renewal.NewContract.RenterOutput.Address, + Value: renterInputSum.Sub(renterCost), + }) + } + + elementBasis, fce, err := s.contractor.V2FileContractElement(req.Renewal.ContractID) + if err != nil { + return fmt.Errorf("failed to get contract element: %w", err) + } + + basis, toSign, err := s.wallet.FundV2Transaction(&renewalTxn, hostCost, true) + if errors.Is(err, wallet.ErrNotEnoughFunds) { + return rhp4.ErrHostFundError + } else if err != nil { + return fmt.Errorf("failed to fund transaction: %w", err) + } + + // update renter inputs to reflect our chain state + if basis != req.Basis { + hostInputs := renewalTxn.SiacoinInputs[len(renewalTxn.SiacoinInputs)-len(req.RenterInputs):] + renewalTxn.SiacoinInputs = renewalTxn.SiacoinInputs[:len(renewalTxn.SiacoinInputs)-len(req.RenterInputs)] + updated, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{renewalTxn}, req.Basis, basis) + if err != nil { + return errorBadRequest("failed to update renter inputs from %q to %q: %v", req.Basis, basis, err) + } + renewalTxn = updated[0] + renewalTxn.SiacoinInputs = append(renewalTxn.SiacoinInputs, hostInputs...) + } + + if elementBasis != basis { + tempTxn := types.V2Transaction{ + FileContractResolutions: []types.V2FileContractResolution{ + {Parent: fce, Resolution: &renewal}, + }, + } + updated, err := s.chain.UpdateV2TransactionSet([]types.V2Transaction{tempTxn}, elementBasis, basis) + if err != nil { + return fmt.Errorf("failed to update contract element: %w", err) + } + fce = updated[0].FileContractResolutions[0].Parent + } + renewalTxn.FileContractResolutions = []types.V2FileContractResolution{ + {Parent: fce, Resolution: &renewal}, + } + s.wallet.SignV2Inputs(&renewalTxn, toSign) + // 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)) + } + + // validate the renter's signature + renewalSigHash := cs.RenewalSigHash(renewal) + if !existing.RenterPublicKey.VerifyHash(renewalSigHash, renterSigResp.RenterRenewalSignature) { + return rhp4.ErrInvalidSignature + } + renewal.RenterSignature = renterSigResp.RenterRenewalSignature + + // apply the renter's signatures + for i, policy := range renterSigResp.RenterSatisfiedPolicies { + renewalTxn.SiacoinInputs[i].SatisfiedPolicy = policy + } + renewal.HostSignature = s.hostKey.SignHash(renewalSigHash) + + // 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 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 renewal set: %v", err) + } + // broadcast the transaction set + s.syncer.BroadcastV2TransactionSet(basis, renewalSet) + + // add the contract to the contractor + err = s.contractor.RenewV2Contract(TransactionSet{ + Transactions: 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 (s *Server) handleRPCVerifySector(stream net.Conn) error { + var req rhp4.RPCVerifySectorRequest + if err := rhp4.ReadRequest(stream, &req); err != nil { + return errorDecodingError("failed to read request: %v", err) + } else if err := req.Validate(s.hostKey.PublicKey()); err != nil { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) + } + prices, token := req.Prices, req.Token + + if err := s.contractor.DebitAccount(token.Account, prices.RPCVerifySectorCost()); err != nil { + return fmt.Errorf("failed to debit account: %w", err) + } + + sector, err := s.sectors.ReadSector(req.Root) + if err != nil { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, err.Error()) + } + + proof := rhp4.BuildSectorProof(sector, req.LeafIndex, req.LeafIndex+1) + resp := rhp4.RPCVerifySectorResponse{ + Proof: proof, + Leaf: ([64]byte)(sector[rhp4.LeafSize*req.LeafIndex:]), + } + return rhp4.WriteResponse(stream, &resp) +} + +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 + } + log = log.With(zap.Stringer("rpc", id)) + + switch id { + case rhp4.RPCSettingsID: + err = s.handleRPCSettings(stream) + // contract + case rhp4.RPCFormContractID: + err = s.handleRPCFormContract(stream) + case rhp4.RPCRefreshContractID: + err = s.handleRPCRefreshContract(stream) + case rhp4.RPCRenewContractID: + err = s.handleRPCRenewContract(stream) + case rhp4.RPCLatestRevisionID: + err = s.handleRPCLatestRevision(stream) + case rhp4.RPCFreeSectorsID: + err = s.handleRPCFreeSectors(stream) + case rhp4.RPCSectorRootsID: + err = s.handleRPCSectorRoots(stream) + // account + case rhp4.RPCAccountBalanceID: + err = s.handleRPCAccountBalance(stream) + case rhp4.RPCFundAccountsID: + err = s.handleRPCFundAccounts(stream) + // sector + case rhp4.RPCAppendSectorsID: + err = s.handleRPCAppendSectors(stream) + case rhp4.RPCReadSectorID: + err = s.handleRPCReadSector(stream, log.Named("RPCReadSector")) + case rhp4.RPCWriteSectorID: + err = s.handleRPCWriteSector(stream) + case rhp4.RPCVerifySectorID: + err = s.handleRPCVerifySector(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 TransportMux, 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) + }() + } +} + +// errorBadRequest is a helper to create an rpc BadRequest error +func errorBadRequest(f string, p ...any) error { + return rhp4.NewRPCError(rhp4.ErrorCodeBadRequest, fmt.Sprintf(f, p...)) +} + +// errorDecodingError is a helper to create an rpc Decoding error +func errorDecodingError(f string, p ...any) error { + return rhp4.NewRPCError(rhp4.ErrorCodeDecoding, fmt.Sprintf(f, p...)) +} + +// NewServer creates a new RHP4 server +func NewServer(pk types.PrivateKey, cm ChainManager, syncer Syncer, contracts Contractor, wallet Wallet, settings Settings, sectors Sectors, opts ...ServerOption) *Server { + s := &Server{ + hostKey: pk, + priceTableValidity: 30 * time.Minute, + contractProofWindowBuffer: 10, + + 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/siamux.go b/rhp/v4/siamux.go new file mode 100644 index 0000000..8f3b524 --- /dev/null +++ b/rhp/v4/siamux.go @@ -0,0 +1,79 @@ +package rhp + +import ( + "context" + "fmt" + "net" + + "go.sia.tech/core/types" + "go.sia.tech/coreutils/chain" + "go.sia.tech/mux/v2" +) + +// A Protocol is a string identifying a network protocol that a host may be +// reached on. +const ( + ProtocolTCPSiaMux chain.Protocol = "siamux" +) + +// siaMuxClientTransport is a TransportClient that uses the SiaMux multiplexer. +type siaMuxClientTransport struct { + m *mux.Mux + peerKey types.PublicKey + close chan struct{} +} + +// Close implements the [TransportClient] interface. +func (t *siaMuxClientTransport) Close() error { + select { + case <-t.close: + default: + close(t.close) + } + return t.m.Close() +} + +func (t *siaMuxClientTransport) FrameSize() int { + return 1440 * 3 // from SiaMux handshake.go +} + +func (t *siaMuxClientTransport) PeerKey() types.PublicKey { + return t.peerKey +} + +// DialStream implements the [TransportClient] interface. The stream lifetime is +// scoped to the context; if the context is canceled, the stream is closed. +func (t *siaMuxClientTransport) DialStream(ctx context.Context) net.Conn { + s := t.m.DialStream() + go func() { + select { + case <-ctx.Done(): + case <-t.close: + } + s.Close() + }() + return s +} + +// DialSiaMux creates a new TransportClient using the SiaMux multiplexer. +func DialSiaMux(ctx context.Context, addr string, peerKey types.PublicKey) (TransportClient, error) { + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("failed to connect to %q: %w", addr, err) + } + + return UpgradeConn(ctx, conn, peerKey) +} + +// UpgradeConn upgrades an existing connection to use the SiaMux multiplexer. +func UpgradeConn(ctx context.Context, conn net.Conn, peerKey types.PublicKey) (TransportClient, error) { + m, err := mux.Dial(conn, peerKey[:]) + if err != nil { + return nil, fmt.Errorf("failed to establish siamux connection: %w", err) + } + return &siaMuxClientTransport{ + m: m, + peerKey: peerKey, + close: make(chan struct{}), + }, nil +} diff --git a/testutil/host.go b/testutil/host.go new file mode 100644 index 0000000..87cda42 --- /dev/null +++ b/testutil/host.go @@ -0,0 +1,403 @@ +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" + "go.sia.tech/mux/v2" + "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.Sectors = (*EphemeralSectorStore)(nil) + +// HasSector checks if a sector is stored in the store. +func (es *EphemeralSectorStore) HasSector(root types.Hash256) (bool, error) { + es.mu.Lock() + defer es.mu.Unlock() + _, ok := es.sectors[root] + return ok, 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 nil, 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 { + 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 + + mu sync.Mutex +} + +var _ rhp4.Contractor = (*EphemeralContractor)(nil) + +// V2FileContractElement returns the contract state element for the given contract ID. +func (ec *EphemeralContractor) V2FileContractElement(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, _ proto4.Usage) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + if len(formationSet.Transactions) == 0 { + return errors.New("expected at least one transaction") + } + formationTxn := formationSet.Transactions[len(formationSet.Transactions)-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, _ proto4.Usage) error { + ec.mu.Lock() + defer ec.mu.Unlock() + + if len(renewalSet.Transactions) == 0 { + return errors.New("expected at least one transaction") + } + renewalTxn := renewalSet.Transactions[len(renewalSet.Transactions)-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, _ proto4.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, _ proto4.Usage) ([]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, usage proto4.Usage) 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(usage.RenterCost()) < 0 { + return errors.New("insufficient funds") + } + ec.accounts[account] = balance.Sub(usage.RenterCost()) + 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.Settings = (*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 3481e24..4fcd97d 100644 --- a/testutil/testutil.go +++ b/testutil/testutil.go @@ -49,13 +49,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) } } }