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 }