diff --git a/.changeset/add_revised_and_renewed_fields_to_rpclatestrevision.md b/.changeset/add_revised_and_renewed_fields_to_rpclatestrevision.md new file mode 100644 index 0000000..c70a89b --- /dev/null +++ b/.changeset/add_revised_and_renewed_fields_to_rpclatestrevision.md @@ -0,0 +1,7 @@ +--- +default: major +--- + +# Add revised and renewed fields to RPCLatestRevision + +Adds two additional fields to the RPCLatestRevision response. The Revisable field indicates whether the host will accept further revisions to the contract. A host will not accept revisions too close to the proof window or revisions on contracts that have already been resolved. The Renewed field indicates whether the contract was renewed. If the contract was renewed, the renter can use FileContractID.V2RenewalID to get the ID of the new contract. diff --git a/go.mod b/go.mod index 0a0dde7..bf49f04 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ toolchain go1.23.2 require ( go.etcd.io/bbolt v1.3.11 - go.sia.tech/core v0.7.2 + go.sia.tech/core v0.8.0 go.sia.tech/mux v1.3.0 go.uber.org/zap v1.27.0 golang.org/x/crypto v0.31.0 diff --git a/go.sum b/go.sum index 8dfe940..25118a3 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.7.2 h1:GAsZ77LE592VEBGNdKeXLV4old/zjLjH11RblHhYbP4= -go.sia.tech/core v0.7.2/go.mod h1:pRlqaLm8amh3b/OBTSqJMEXmhPT14RxjntlKPySRNpA= +go.sia.tech/core v0.8.0 h1:J6vZQlVhpj4bTVeuC2GKkfkGEs8jf0j651Kl1wwOxjg= +go.sia.tech/core v0.8.0/go.mod h1:Wj1qzvpMM2rqEQjwWJEbCBbe9VWX/mSJUu2Y2ABl1QA= 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 index d668025..d265995 100644 --- a/rhp/v4/options.go +++ b/rhp/v4/options.go @@ -13,11 +13,3 @@ func WithPriceTableValidity(validity time.Duration) ServerOption { 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 index 38d6320..1600453 100644 --- a/rhp/v4/rpc.go +++ b/rhp/v4/rpc.go @@ -474,11 +474,10 @@ func RPCFundAccounts(ctx context.Context, t TransportClient, cs consensus.State, } // RPCLatestRevision returns the latest revision of a contract. -func RPCLatestRevision(ctx context.Context, t TransportClient, contractID types.FileContractID) (types.V2FileContract, error) { +func RPCLatestRevision(ctx context.Context, t TransportClient, contractID types.FileContractID) (resp rhp4.RPCLatestRevisionResponse, err error) { req := rhp4.RPCLatestRevisionRequest{ContractID: contractID} - var resp rhp4.RPCLatestRevisionResponse - err := callSingleRoundtripRPC(ctx, t, rhp4.RPCLatestRevisionID, &req, &resp) - return resp.Contract, err + err = callSingleRoundtripRPC(ctx, t, rhp4.RPCLatestRevisionID, &req, &resp) + return } // RPCSectorRoots returns the sector roots for a contract. diff --git a/rhp/v4/rpc_test.go b/rhp/v4/rpc_test.go index a54ea37..266b06c 100644 --- a/rhp/v4/rpc_test.go +++ b/rhp/v4/rpc_test.go @@ -49,7 +49,7 @@ func (fs *fundAndSign) Address() types.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)) + rs := rhp4.NewServer(hostKey, cm, s, c, w, sr, ss, rhp4.WithPriceTableValidity(2*time.Minute)) hostAddr := testutil.ServeSiaMux(tb, rs, log.Named("siamux")) transport, err := rhp4.DialSiaMux(context.Background(), hostAddr, hostKey.PublicKey()) @@ -406,6 +406,15 @@ func TestRPCRefresh(t *testing.T) { t.Fatal(err) } revision.Revision = aRes.Revision + + rs, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) + if err != nil { + t.Fatal(err) + } else if rs.Renewed { + t.Fatal("expected contract to not be renewed") + } else if !rs.Revisable { + t.Fatal("expected contract to be revisable") + } return revision } @@ -450,6 +459,15 @@ func TestRPCRefresh(t *testing.T) { } else if !hostKey.PublicKey().VerifyHash(sigHash, refreshResult.Contract.Revision.HostSignature) { t.Fatal("host signature verification failed") } + + rs, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) + if err != nil { + t.Fatal(err) + } else if !rs.Renewed { + t.Fatal("expected contract to be renewed") + } else if rs.Revisable { + t.Fatal("expected contract to not be revisable") + } }) } @@ -593,6 +611,15 @@ func TestRPCRenew(t *testing.T) { } else if !hostKey.PublicKey().VerifyHash(sigHash, renewResult.Contract.Revision.HostSignature) { t.Fatal("host signature verification failed") } + + rs, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) + if err != nil { + t.Fatal(err) + } else if !rs.Renewed { + t.Fatal("expected contract to be renewed") + } else if rs.Revisable { + t.Fatal("expected contract to not be revisable") + } }) t.Run("full rollover", func(t *testing.T) { @@ -622,6 +649,15 @@ func TestRPCRenew(t *testing.T) { } else if !hostKey.PublicKey().VerifyHash(sigHash, renewResult.Contract.Revision.HostSignature) { t.Fatal("host signature verification failed") } + + rs, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) + if err != nil { + t.Fatal(err) + } else if !rs.Renewed { + t.Fatal("expected contract to be renewed") + } else if rs.Revisable { + t.Fatal("expected contract to not be revisable") + } }) t.Run("no rollover", func(t *testing.T) { @@ -651,6 +687,15 @@ func TestRPCRenew(t *testing.T) { } else if !hostKey.PublicKey().VerifyHash(sigHash, renewResult.Contract.Revision.HostSignature) { t.Fatal("host signature verification failed") } + + rs, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) + if err != nil { + t.Fatal(err) + } else if !rs.Renewed { + t.Fatal("expected contract to be renewed") + } else if rs.Revisable { + t.Fatal("expected contract to not be revisable") + } }) } @@ -907,10 +952,12 @@ func TestAppendSectors(t *testing.T) { assertLastRevision := func(t *testing.T) { t.Helper() - lastRev, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) + rs, err := rhp4.RPCLatestRevision(context.Background(), transport, revision.ID) if err != nil { t.Fatal(err) - } else if !reflect.DeepEqual(lastRev, revision.Revision) { + } + lastRev := rs.Contract + if !reflect.DeepEqual(lastRev, revision.Revision) { t.Log(lastRev) t.Log(revision.Revision) t.Fatalf("expected last revision to match") diff --git a/rhp/v4/server.go b/rhp/v4/server.go index 16f6ec2..3936878 100644 --- a/rhp/v4/server.go +++ b/rhp/v4/server.go @@ -88,8 +88,10 @@ type ( // A RevisionState pairs a contract revision with its sector roots. RevisionState struct { - Revision types.V2FileContract - Roots []types.Hash256 + Revision types.V2FileContract + Renewed bool + Revisable bool + Roots []types.Hash256 } // Contractor is an interface for managing a host's contracts. @@ -123,9 +125,8 @@ type ( // A Server handles incoming RHP4 RPC. Server struct { - hostKey types.PrivateKey - priceTableValidity time.Duration - contractProofWindowBuffer uint64 + hostKey types.PrivateKey + priceTableValidity time.Duration chain ChainManager syncer Syncer @@ -136,18 +137,15 @@ type ( } ) -func (s *Server) lockContractForRevision(contractID types.FileContractID) (rev RevisionState, unlock func(), _ error) { - rev, unlock, err := s.contractor.LockV2Contract(contractID) +func (s *Server) lockContractForRevision(contractID types.FileContractID) (RevisionState, func(), error) { + rs, 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 { + } else if !rs.Revisable { 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 RevisionState{}, nil, errorBadRequest("contract is not revisable") } - return rev, unlock, nil + return rs, unlock, nil } func (s *Server) handleRPCSettings(stream net.Conn) error { @@ -451,7 +449,9 @@ func (s *Server) handleRPCLatestRevision(stream net.Conn) error { unlock() return rhp4.WriteResponse(stream, &rhp4.RPCLatestRevisionResponse{ - Contract: state.Revision, + Contract: state.Revision, + Revisable: state.Revisable, + Renewed: state.Renewed, }) } @@ -1118,9 +1118,8 @@ func errorDecodingError(f string, p ...any) error { // 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, + hostKey: pk, + priceTableValidity: 30 * time.Minute, chain: cm, syncer: syncer, diff --git a/testutil/host.go b/testutil/host.go index b5b4407..5fb8bc5 100644 --- a/testutil/host.go +++ b/testutil/host.go @@ -93,10 +93,14 @@ func (ec *EphemeralContractor) LockV2Contract(contractID types.FileContractID) ( return rhp4.RevisionState{}, nil, errors.New("contract not found") } + _, renewed := ec.contracts[contractID.V2RenewalID()] + var once sync.Once return rhp4.RevisionState{ - Revision: rev, - Roots: ec.roots[contractID], + Revision: rev, + Revisable: !renewed && ec.tip.Height < rev.ProofHeight, + Renewed: renewed, + Roots: ec.roots[contractID], }, func() { once.Do(func() { ec.mu.Lock() @@ -159,8 +163,6 @@ func (ec *EphemeralContractor) RenewV2Contract(renewalSet rhp4.TransactionSet, _ 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() @@ -175,7 +177,6 @@ func (ec *EphemeralContractor) RenewV2Contract(renewalSet rhp4.TransactionSet, _ return errors.New("invalid host signature") } - delete(ec.contracts, existingID) // remove the existing contract ec.contracts[contractID] = renewal.NewContract ec.roots[contractID] = append([]types.Hash256(nil), ec.roots[existingID]...) return nil @@ -210,7 +211,7 @@ func (ec *EphemeralContractor) ReviseV2Contract(contractID types.FileContractID, func (ec *EphemeralContractor) AccountBalance(account proto4.Account) (types.Currency, error) { ec.mu.Lock() defer ec.mu.Unlock() - balance, _ := ec.accounts[account] + balance := ec.accounts[account] return balance, nil }