github.com/decred/politeia@v1.4.0/politeiad/backendv2/tstorebe/plugins/pi/cmds.go (about)

     1  // Copyright (c) 2021 The Decred developers
     2  // Use of this source code is governed by an ISC
     3  // license that can be found in the LICENSE file.
     4  
     5  package pi
     6  
     7  import (
     8  	"bytes"
     9  	"encoding/base64"
    10  	"encoding/hex"
    11  	"encoding/json"
    12  	"fmt"
    13  	"sort"
    14  	"strconv"
    15  	"time"
    16  
    17  	"github.com/pkg/errors"
    18  
    19  	backend "github.com/decred/politeia/politeiad/backendv2"
    20  	"github.com/decred/politeia/politeiad/backendv2/tstorebe/store"
    21  	"github.com/decred/politeia/politeiad/plugins/pi"
    22  	"github.com/decred/politeia/politeiad/plugins/ticketvote"
    23  	"github.com/decred/politeia/util"
    24  )
    25  
    26  const (
    27  	pluginID = pi.PluginID
    28  
    29  	// Blob entry data descriptors
    30  	dataDescriptorBillingStatus = pluginID + "-billingstatus-v1"
    31  )
    32  
    33  var (
    34  	// billingStatusChanges contains the allowed billing status transitions. If
    35  	// billingStatusChanges[currentStatus][newStatus] exists then the the billing
    36  	// status transition is allowed.
    37  	billingStatusChanges = map[pi.BillingStatusT]map[pi.BillingStatusT]struct{}{
    38  		// Active to...
    39  		pi.BillingStatusActive: {
    40  			pi.BillingStatusClosed:    {},
    41  			pi.BillingStatusCompleted: {},
    42  		},
    43  		// Closed to...
    44  		pi.BillingStatusClosed: {
    45  			pi.BillingStatusActive:    {},
    46  			pi.BillingStatusCompleted: {},
    47  		},
    48  		// Completed to...
    49  		pi.BillingStatusCompleted: {
    50  			pi.BillingStatusActive: {},
    51  			pi.BillingStatusClosed: {},
    52  		},
    53  	}
    54  )
    55  
    56  // cmdSetBillingStatus sets proposal's billing status.
    57  func (p *piPlugin) cmdSetBillingStatus(token []byte, payload string) (string, error) {
    58  	// Decode payload
    59  	var sbs pi.SetBillingStatus
    60  	err := json.Unmarshal([]byte(payload), &sbs)
    61  	if err != nil {
    62  		return "", err
    63  	}
    64  
    65  	// Verify token
    66  	err = tokenMatches(token, sbs.Token)
    67  	if err != nil {
    68  		return "", err
    69  	}
    70  
    71  	// Verify billing status
    72  	switch sbs.Status {
    73  	case pi.BillingStatusActive, pi.BillingStatusClosed,
    74  		pi.BillingStatusCompleted:
    75  		// These are allowed; continue
    76  
    77  	default:
    78  		// Billing status is invalid
    79  		return "", backend.PluginError{
    80  			PluginID:     pi.PluginID,
    81  			ErrorCode:    uint32(pi.ErrorCodeBillingStatusInvalid),
    82  			ErrorContext: "invalid billing status",
    83  		}
    84  	}
    85  
    86  	// Verify signature
    87  	msg := sbs.Token + strconv.FormatUint(uint64(sbs.Status), 10) + sbs.Reason
    88  	err = util.VerifySignature(sbs.Signature, sbs.PublicKey, msg)
    89  	if err != nil {
    90  		return "", convertSignatureError(err)
    91  	}
    92  
    93  	// Ensure reason is provided when status is set to closed.
    94  	if sbs.Status == pi.BillingStatusClosed && sbs.Reason == "" {
    95  		return "", backend.PluginError{
    96  			PluginID:  pi.PluginID,
    97  			ErrorCode: uint32(pi.ErrorCodeBillingStatusChangeNotAllowed),
    98  			ErrorContext: "must provide a reason when setting " +
    99  				"billing status to closed",
   100  		}
   101  	}
   102  
   103  	// Ensure proposal's vote ended and it was approved
   104  	vsr, err := p.voteSummary(token)
   105  	if err != nil {
   106  		return "", err
   107  	}
   108  	if vsr.Status != ticketvote.VoteStatusApproved {
   109  		return "", backend.PluginError{
   110  			PluginID:  pi.PluginID,
   111  			ErrorCode: uint32(pi.ErrorCodeBillingStatusChangeNotAllowed),
   112  			ErrorContext: "setting billing status is allowed only if " +
   113  				"proposal vote was approved",
   114  		}
   115  	}
   116  
   117  	// Ensure that this is not an RFP proposal. RFP proposals do not
   118  	// request funding and do not bill against the treasury, which
   119  	// means that they don't have a billing status. RFP submission
   120  	// proposals, however, do request funding and do have a billing
   121  	// status.
   122  	r, err := p.record(backend.RecordRequest{
   123  		Token:     token,
   124  		Filenames: []string{ticketvote.FileNameVoteMetadata},
   125  	})
   126  	if err != nil {
   127  		return "", err
   128  	}
   129  	vm, err := voteMetadataDecode(r.Files)
   130  	if err != nil {
   131  		return "", err
   132  	}
   133  	if isRFP(vm) {
   134  		return "", backend.PluginError{
   135  			PluginID:     pi.PluginID,
   136  			ErrorCode:    uint32(pi.ErrorCodeBillingStatusChangeNotAllowed),
   137  			ErrorContext: "rfp proposals do not have a billing status",
   138  		}
   139  	}
   140  
   141  	// Ensure number of billing status changes does not exceed the maximum
   142  	bscs, err := p.billingStatusChanges(token)
   143  	if err != nil {
   144  		return "", err
   145  	}
   146  	if uint32(len(bscs)+1) > p.billingStatusChangesMax {
   147  		return "", backend.PluginError{
   148  			PluginID:  pi.PluginID,
   149  			ErrorCode: uint32(pi.ErrorCodeBillingStatusChangeNotAllowed),
   150  			ErrorContext: "number of billing status changes exceeds the " +
   151  				"maximum allowed number of billing status changes",
   152  		}
   153  	}
   154  
   155  	// Ensure billing status change transition is valid
   156  	currStatus := proposalBillingStatus(vsr.Status, bscs)
   157  	_, ok := billingStatusChanges[currStatus][sbs.Status]
   158  	if !ok {
   159  		return "", backend.PluginError{
   160  			PluginID:  pi.PluginID,
   161  			ErrorCode: uint32(pi.ErrorCodeBillingStatusChangeNotAllowed),
   162  			ErrorContext: fmt.Sprintf("invalid billing status transition, "+
   163  				"%v to %v is not allowed", pi.BillingStatuses[currStatus],
   164  				pi.BillingStatuses[sbs.Status]),
   165  		}
   166  	}
   167  
   168  	// Save billing status change
   169  	receipt := p.identity.SignMessage([]byte(sbs.Signature))
   170  	bsc := pi.BillingStatusChange{
   171  		Token:     sbs.Token,
   172  		Status:    sbs.Status,
   173  		Reason:    sbs.Reason,
   174  		PublicKey: sbs.PublicKey,
   175  		Signature: sbs.Signature,
   176  		Timestamp: time.Now().Unix(),
   177  		Receipt:   hex.EncodeToString(receipt[:]),
   178  	}
   179  	err = p.billingStatusSave(token, bsc)
   180  	if err != nil {
   181  		return "", err
   182  	}
   183  
   184  	// Prepare reply
   185  	sbsr := pi.SetBillingStatusReply{
   186  		Timestamp: bsc.Timestamp,
   187  		Receipt:   bsc.Receipt,
   188  	}
   189  	reply, err := json.Marshal(sbsr)
   190  	if err != nil {
   191  		return "", err
   192  	}
   193  
   194  	return string(reply), nil
   195  }
   196  
   197  // tokenMatches verifies that the command token (the token for the record that
   198  // this plugin command is being executed on) matches the payload token (the
   199  // token that the plugin command payload contains that is typically used in the
   200  // payload signature). The payload token must be the full length token.
   201  func tokenMatches(cmdToken []byte, payloadToken string) error {
   202  	pt, err := tokenDecode(payloadToken)
   203  	if err != nil {
   204  		return backend.PluginError{
   205  			PluginID:     pi.PluginID,
   206  			ErrorCode:    uint32(pi.ErrorCodeTokenInvalid),
   207  			ErrorContext: util.TokenRegexp(),
   208  		}
   209  	}
   210  	if !bytes.Equal(cmdToken, pt) {
   211  		return backend.PluginError{
   212  			PluginID:  pi.PluginID,
   213  			ErrorCode: uint32(pi.ErrorCodeTokenInvalid),
   214  			ErrorContext: fmt.Sprintf("payload token does not "+
   215  				"match command token: got %x, want %x",
   216  				pt, cmdToken),
   217  		}
   218  	}
   219  	return nil
   220  }
   221  
   222  // cmdBillingStatusChanges returns the billing status changes of a proposal.
   223  func (p *piPlugin) cmdBillingStatusChanges(token []byte) (string, error) {
   224  	// Get billing status changes
   225  	bscs, err := p.billingStatusChanges(token)
   226  	if err != nil {
   227  		return "", err
   228  	}
   229  
   230  	// Prepare reply
   231  	bscsr := pi.BillingStatusChangesReply{
   232  		BillingStatusChanges: bscs,
   233  	}
   234  	reply, err := json.Marshal(bscsr)
   235  	if err != nil {
   236  		return "", err
   237  	}
   238  
   239  	return string(reply), nil
   240  }
   241  
   242  // cmdSummary returns the pi summary of a proposal.
   243  func (p *piPlugin) cmdSummary(token []byte) (string, error) {
   244  	// Get the proposal status
   245  	propStatus, err := p.getProposalStatus(token)
   246  	if err != nil {
   247  		return "", err
   248  	}
   249  
   250  	// Prepare the reply
   251  	sr := pi.SummaryReply{
   252  		Summary: pi.ProposalSummary{
   253  			Status: propStatus,
   254  		},
   255  	}
   256  
   257  	reply, err := json.Marshal(sr)
   258  	if err != nil {
   259  		return "", err
   260  	}
   261  
   262  	return string(reply), nil
   263  }
   264  
   265  // proposalBillingStatus accepts proposal's vote status with the billing status
   266  // changes and returns the proposal's billing status.
   267  func proposalBillingStatus(vs ticketvote.VoteStatusT, bscs []pi.BillingStatusChange) pi.BillingStatusT {
   268  	// If proposal vote wasn't approved,
   269  	// return invalid billing status.
   270  	if vs != ticketvote.VoteStatusApproved {
   271  		return pi.BillingStatusInvalid
   272  	}
   273  
   274  	var bs pi.BillingStatusT
   275  	if len(bscs) == 0 {
   276  		// Proposals that have been approved, but have not had
   277  		// their billing status set yet are considered to be
   278  		// active.
   279  		bs = pi.BillingStatusActive
   280  	} else {
   281  		// Use the status from the most recent billing status
   282  		// change.
   283  		bs = bscs[len(bscs)-1].Status
   284  	}
   285  
   286  	return bs
   287  }
   288  
   289  // record returns a record from the backend with it's contents filtered
   290  // according to the provided record request.
   291  //
   292  // A backend ErrRecordNotFound error is returned if the record is not found.
   293  func (p *piPlugin) record(rr backend.RecordRequest) (*backend.Record, error) {
   294  	if rr.Token == nil {
   295  		return nil, errors.Errorf("token not provided")
   296  	}
   297  	reply, err := p.backend.Records([]backend.RecordRequest{rr})
   298  	if err != nil {
   299  		return nil, err
   300  	}
   301  	r, ok := reply[hex.EncodeToString(rr.Token)]
   302  	if !ok {
   303  		return nil, backend.ErrRecordNotFound
   304  	}
   305  	return &r, nil
   306  }
   307  
   308  // recordAbridged returns a record with all files omitted.
   309  //
   310  // A backend ErrRecordNotFound error is returned if the record is not found.
   311  func (p *piPlugin) recordAbridged(token []byte) (*backend.Record, error) {
   312  	rr := backend.RecordRequest{
   313  		Token:        token,
   314  		OmitAllFiles: true,
   315  	}
   316  	return p.record(rr)
   317  }
   318  
   319  // convertSignatureError converts a util SignatureError to a backend
   320  // PluginError that contains a pi plugin error code.
   321  func convertSignatureError(err error) backend.PluginError {
   322  	var e util.SignatureError
   323  	var s pi.ErrorCodeT
   324  	if errors.As(err, &e) {
   325  		switch e.ErrorCode {
   326  		case util.ErrorStatusPublicKeyInvalid:
   327  			s = pi.ErrorCodePublicKeyInvalid
   328  		case util.ErrorStatusSignatureInvalid:
   329  			s = pi.ErrorCodeSignatureInvalid
   330  		}
   331  	}
   332  	return backend.PluginError{
   333  		PluginID:     pi.PluginID,
   334  		ErrorCode:    uint32(s),
   335  		ErrorContext: e.ErrorContext,
   336  	}
   337  }
   338  
   339  // billingStatusSave saves a BillingStatusChange to the backend.
   340  func (p *piPlugin) billingStatusSave(token []byte, bsc pi.BillingStatusChange) error {
   341  	// Prepare blob
   342  	be, err := billingStatusEncode(bsc)
   343  	if err != nil {
   344  		return err
   345  	}
   346  
   347  	// Save blob
   348  	return p.tstore.BlobSave(token, *be)
   349  }
   350  
   351  // billingStatusChanges returns the billing status changes of a proposal.
   352  func (p *piPlugin) billingStatusChanges(token []byte) ([]pi.BillingStatusChange, error) {
   353  	// Retrieve blobs
   354  	blobs, err := p.tstore.BlobsByDataDesc(token,
   355  		[]string{dataDescriptorBillingStatus})
   356  	if err != nil {
   357  		return nil, err
   358  	}
   359  
   360  	// Decode blobs
   361  	statusChanges := make([]pi.BillingStatusChange, 0, len(blobs))
   362  	for _, v := range blobs {
   363  		a, err := billingStatusDecode(v)
   364  		if err != nil {
   365  			return nil, err
   366  		}
   367  		statusChanges = append(statusChanges, *a)
   368  	}
   369  
   370  	// Sanity check. They should already be sorted from oldest to
   371  	// newest.
   372  	sort.SliceStable(statusChanges, func(i, j int) bool {
   373  		return statusChanges[i].Timestamp < statusChanges[j].Timestamp
   374  	})
   375  
   376  	return statusChanges, nil
   377  }
   378  
   379  // billingStatusEncode encodes a BillingStatusChange into a BlobEntry.
   380  func billingStatusEncode(bsc pi.BillingStatusChange) (*store.BlobEntry, error) {
   381  	data, err := json.Marshal(bsc)
   382  	if err != nil {
   383  		return nil, err
   384  	}
   385  	hint, err := json.Marshal(
   386  		store.DataDescriptor{
   387  			Type:       store.DataTypeStructure,
   388  			Descriptor: dataDescriptorBillingStatus,
   389  		})
   390  	if err != nil {
   391  		return nil, err
   392  	}
   393  	be := store.NewBlobEntry(hint, data)
   394  	return &be, nil
   395  }
   396  
   397  // billingStatusDecode decodes a BlobEntry into a BillingStatusChange.
   398  func billingStatusDecode(be store.BlobEntry) (*pi.BillingStatusChange, error) {
   399  	// Decode and validate data hint
   400  	b, err := base64.StdEncoding.DecodeString(be.DataHint)
   401  	if err != nil {
   402  		return nil, fmt.Errorf("decode DataHint: %v", err)
   403  	}
   404  	var dd store.DataDescriptor
   405  	err = json.Unmarshal(b, &dd)
   406  	if err != nil {
   407  		return nil, fmt.Errorf("unmarshal DataHint: %v", err)
   408  	}
   409  	if dd.Descriptor != dataDescriptorBillingStatus {
   410  		return nil, fmt.Errorf("unexpected data descriptor: got %v, "+
   411  			"want %v", dd.Descriptor, dataDescriptorBillingStatus)
   412  	}
   413  
   414  	// Decode data
   415  	b, err = base64.StdEncoding.DecodeString(be.Data)
   416  	if err != nil {
   417  		return nil, fmt.Errorf("decode Data: %v", err)
   418  	}
   419  	digest, err := hex.DecodeString(be.Digest)
   420  	if err != nil {
   421  		return nil, fmt.Errorf("decode digest: %v", err)
   422  	}
   423  	if !bytes.Equal(util.Digest(b), digest) {
   424  		return nil, fmt.Errorf("data is not coherent; got %x, want %x",
   425  			util.Digest(b), digest)
   426  	}
   427  	var bsc pi.BillingStatusChange
   428  	err = json.Unmarshal(b, &bsc)
   429  	if err != nil {
   430  		return nil, fmt.Errorf("unmarshal AuthDetails: %v", err)
   431  	}
   432  
   433  	return &bsc, nil
   434  }