CCF
Loading...
Searching...
No Matches
proposals.h
Go to the documentation of this file.
1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the Apache 2.0 License.
3#pragma once
4
12
13namespace ccf::gov::endpoints
14{
15 namespace detail
16 {
18 {
19 enum class Status
20 {
23 TooOld
25
26 // May be empty, a colliding proposal ID, or a min_created_at value,
27 // depending on status
28 std::string info = "";
29 };
30
32 ccf::kv::Tx& tx,
33 const std::string& created_at,
34 const std::vector<uint8_t>& request_digest,
35 const ccf::ProposalId& proposal_id)
36 {
37 auto cose_recent_proposals =
38 tx.rw<ccf::COSERecentProposals>(ccf::Tables::COSE_RECENT_PROPOSALS);
39 auto key = fmt::format("{}:{}", created_at, ds::to_hex(request_digest));
40
41 if (cose_recent_proposals->has(key))
42 {
43 auto colliding_proposal_id = cose_recent_proposals->get(key).value();
44 return {
46 colliding_proposal_id};
47 }
48
49 std::vector<std::string> replay_keys;
50 cose_recent_proposals->foreach_key(
51 [&replay_keys](const std::string& replay_key) {
52 replay_keys.push_back(replay_key);
53 return true;
54 });
55
56 std::sort(replay_keys.begin(), replay_keys.end());
57
58 // New proposal must be more recent than median proposal kept
59 if (!replay_keys.empty())
60 {
61 const auto [min_created_at, _] =
62 ccf::nonstd::split_1(replay_keys[replay_keys.size() / 2], ":");
63 auto [key_ts, __] = ccf::nonstd::split_1(key, ":");
64 if (key_ts < min_created_at)
65 {
66 return {
68 std::string(min_created_at)};
69 }
70 }
71
72 size_t window_size = ccf::default_recent_cose_proposals_window_size;
73 auto config_handle =
74 tx.ro<ccf::Configuration>(ccf::Tables::CONFIGURATION);
75 auto config = config_handle->get();
76 if (
77 config.has_value() &&
78 config->recent_cose_proposals_window_size.has_value())
79 {
80 window_size = config->recent_cose_proposals_window_size.value();
81 }
82 cose_recent_proposals->put(key, proposal_id);
83 // Only keep the most recent window_size proposals, to avoid
84 // unbounded memory usage
85 if (replay_keys.size() >= (window_size - 1) /* We just added one */)
86 {
87 for (size_t i = 0; i < (replay_keys.size() - (window_size - 1)); i++)
88 {
89 cose_recent_proposals->remove(replay_keys[i]);
90 }
91 }
93 }
94
96 ccf::kv::Tx& tx,
97 const MemberId& caller_id,
98 const std::span<const uint8_t>& cose_sign1)
99 {
100 auto cose_governance_history =
101 tx.wo<ccf::COSEGovernanceHistory>(ccf::Tables::COSE_GOV_HISTORY);
102 cose_governance_history->put(
103 caller_id, {cose_sign1.begin(), cose_sign1.end()});
104 }
105
107 ccf::kv::Tx& tx, const ProposalId& proposal_id)
108 {
109 auto p = tx.rw<ccf::jsgov::ProposalMap>(jsgov::Tables::PROPOSALS);
110 auto pi =
111 tx.rw<ccf::jsgov::ProposalInfoMap>(jsgov::Tables::PROPOSALS_INFO);
112 std::vector<ProposalId> to_be_removed;
113 pi->foreach(
114 [&to_be_removed, &proposal_id](
115 const ProposalId& pid, const ccf::jsgov::ProposalInfo& pinfo) {
116 if (pid != proposal_id && pinfo.state != ProposalState::OPEN)
117 {
118 to_be_removed.push_back(pid);
119 }
120 return true;
121 });
122 for (const auto& pr : to_be_removed)
123 {
124 p->remove(pr);
125 pi->remove(pr);
126 }
127 }
128
129 // Evaluate JS functions on this proposal. Result is presented in modified
130 // proposal_info argument, which is written back to the KV by this function
133 ccf::NetworkState& network,
134 ccf::kv::Tx& tx,
135 const ProposalId& proposal_id,
136 const std::span<const uint8_t>& proposal_bytes,
137 ccf::jsgov::ProposalInfo& proposal_info,
138 const std::string& constitution)
139 {
140 // Create some temporaries to store resolution progress. These are written
141 // to proposal_info, and the KV, when proposals leave the Open state.
142 ccf::jsgov::Votes votes = {};
143 ccf::jsgov::VoteFailures vote_failures = {};
144
145 const std::string_view proposal{
146 (const char*)proposal_bytes.data(), proposal_bytes.size()};
147
148 auto proposal_info_handle = tx.template rw<ccf::jsgov::ProposalInfoMap>(
149 jsgov::Tables::PROPOSALS_INFO);
150
151 // Evaluate ballots
152 for (const auto& [mid, mb] : proposal_info.ballots)
153 {
155
156 auto ballot_func = js_context.get_exported_function(
157 mb,
158 "vote",
159 fmt::format(
160 "{}[{}].ballots[{}]",
161 ccf::jsgov::Tables::PROPOSALS_INFO,
162 proposal_id,
163 mid));
164
165 std::vector<js::core::JSWrappedValue> argv = {
166 js_context.new_string(proposal),
167 js_context.new_string(proposal_info.proposer_id.value())};
168
169 auto val = js_context.call_with_rt_options(
170 ballot_func,
171 argv,
172 tx.ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
174
175 if (!val.is_exception())
176 {
177 votes[mid] = val.is_true();
178 }
179 else
180 {
181 auto [reason, trace] = js_context.error_message();
182
183 if (js_context.interrupt_data.request_timed_out)
184 {
185 reason = "Operation took too long to complete.";
186 }
187 vote_failures[mid] = ccf::jsgov::Failure{reason, trace};
188 }
189 }
190
191 // Evaluate resolve function
192 // NB: Since the only change from the calls to `apply` is some tentative
193 // votes, there is no change to the proposal stored in the KV.
194 {
195 {
197
198 auto resolve_func = js_context.get_exported_function(
199 constitution,
200 "resolve",
201 fmt::format("{}[0]", ccf::Tables::CONSTITUTION));
202
203 std::vector<js::core::JSWrappedValue> argv;
204 argv.push_back(js_context.new_string(proposal));
205
206 argv.push_back(
207 js_context.new_string(proposal_info.proposer_id.value()));
208
209 auto vs = js_context.new_array();
210 size_t index = 0;
211 for (auto& [mid, vote] : votes)
212 {
213 auto v = JS_NewObject(js_context);
214 auto member_id =
215 JS_NewStringLen(js_context, mid.data(), mid.size());
216 JS_DefinePropertyValueStr(
217 js_context, v, "member_id", member_id, JS_PROP_C_W_E);
218 auto vote_status = JS_NewBool(js_context, vote);
219 JS_DefinePropertyValueStr(
220 js_context, v, "vote", vote_status, JS_PROP_C_W_E);
221 JS_DefinePropertyValueUint32(
222 js_context, vs.val, index++, v, JS_PROP_C_W_E);
223 }
224 argv.push_back(vs);
225
226 // Also pass the proposal_id as a string. This is useful for proposals
227 // that want to refer to themselves in the resolve function, for
228 // example to examine/distinguish themselves other pending proposals.
229 argv.push_back(js_context.new_string(proposal_id));
230
231 auto val = js_context.call_with_rt_options(
232 resolve_func,
233 argv,
234 tx.ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
236
237 if (val.is_exception())
238 {
239 proposal_info.state = ProposalState::FAILED;
240 auto [reason, trace] = js_context.error_message();
241 if (js_context.interrupt_data.request_timed_out)
242 {
243 reason = "Operation took too long to complete.";
244 }
245 proposal_info.failure = ccf::jsgov::Failure{
246 fmt::format("Failed to resolve(): {}", reason), trace};
247 }
248 else
249 {
250 auto status = js_context.to_str(val).value_or("");
251 // NB: It is not possible to produce every possible ProposalState
252 // here! WITHDRAWN and DROPPED are states that we transition to
253 // elsewhere, but not valid return values from resolve()
254 const std::unordered_map<std::string, ProposalState>
255 js_str_to_status = {
256 {"Open", ProposalState::OPEN},
257 {"Accepted", ProposalState::ACCEPTED},
258 {"Rejected", ProposalState::REJECTED}};
259 const auto it = js_str_to_status.find(status);
260 if (it != js_str_to_status.end())
261 {
262 proposal_info.state = it->second;
263 }
264 else
265 {
266 proposal_info.state = ProposalState::FAILED;
267 proposal_info.failure = ccf::jsgov::Failure{
268 fmt::format(
269 "resolve() returned invalid status value: \"{}\"", status),
270 std::nullopt // No trace
271 };
272 }
273 }
274
275 // Ensure resolved proposal_info is visible in the KV
276 proposal_info_handle->put(proposal_id, proposal_info);
277 }
278
279 if (proposal_info.state != ProposalState::OPEN)
280 {
282
283 // Write now-permanent values back to proposal_state, and into the
284 // KV
285 proposal_info.final_votes = votes;
286 proposal_info.vote_failures = vote_failures;
287 proposal_info_handle->put(proposal_id, proposal_info);
288
289 if (proposal_info.state == ProposalState::ACCEPTED)
290 {
291 // Evaluate apply function
292 auto gov_effects =
294 if (gov_effects == nullptr)
295 {
296 throw std::logic_error(
297 "Unexpected: Could not access GovEffects subsytem");
298 }
299
301
302 js_context.add_extension(
303 std::make_shared<ccf::js::extensions::NodeExtension>(
304 gov_effects.get(), &tx));
305 js_context.add_extension(
306 std::make_shared<ccf::js::extensions::NetworkExtension>(
307 &network, &tx));
308 js_context.add_extension(
309 std::make_shared<ccf::js::extensions::GovEffectsExtension>(&tx));
310
311 auto apply_func = js_context.get_exported_function(
312 constitution,
313 "apply",
314 fmt::format("{}[0]", ccf::Tables::CONSTITUTION));
315
316 std::vector<js::core::JSWrappedValue> argv = {
317 js_context.new_string(proposal),
318 js_context.new_string(proposal_id)};
319
320 auto val = js_context.call_with_rt_options(
321 apply_func,
322 argv,
323 tx.ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
325
326 if (val.is_exception())
327 {
328 proposal_info.state = ProposalState::FAILED;
329 auto [reason, trace] = js_context.error_message();
330 if (js_context.interrupt_data.request_timed_out)
331 {
332 reason = "Operation took too long to complete.";
333 }
334 proposal_info.failure = ccf::jsgov::Failure{
335 fmt::format("Failed to apply(): {}", reason), trace};
336
337 // Update final proposal_info (in KV) again, with failure info
338 proposal_info_handle->put(proposal_id, proposal_info);
339 }
340 }
341 }
342 }
343 }
344
346 const ProposalId& proposal_id, const ccf::jsgov::ProposalInfo& summary)
347 {
348 auto response_body = nlohmann::json::object();
349
350 response_body["proposalId"] = proposal_id;
351 response_body["proposerId"] = summary.proposer_id;
352 response_body["proposalState"] = summary.state;
353 response_body["ballotCount"] = summary.ballots.size();
354
355 std::optional<ccf::jsgov::Votes> votes = summary.final_votes;
356
357 if (votes.has_value())
358 {
359 auto final_votes = nlohmann::json::object();
360 for (const auto& [voter_id, vote_result] : *votes)
361 {
362 final_votes[voter_id.value()] = vote_result;
363 }
364 response_body["finalVotes"] = final_votes;
365 }
366
367 if (summary.vote_failures.has_value())
368 {
369 auto vote_failures = nlohmann::json::object();
370 for (const auto& [failer_id, failure] : *summary.vote_failures)
371 {
372 vote_failures[failer_id.value()] = failure;
373 }
374 response_body["voteFailures"] = vote_failures;
375 }
376
377 if (summary.failure.has_value())
378 {
379 auto failure = nlohmann::json::object();
380 response_body["failure"] = *summary.failure;
381 }
382
383 return response_body;
384 }
385 }
386
389 NetworkState& network,
390 ccf::AbstractNodeContext& node_context)
391 {
393 auto create_proposal = [&](auto& ctx, ApiVersion api_version) {
394 switch (api_version)
395 {
397 case ApiVersion::v1:
398 default:
399 {
400 const auto& cose_ident =
401 ctx.template get_caller<ccf::MemberCOSESign1AuthnIdentity>();
402
403 std::span<const uint8_t> proposal_body = cose_ident.content;
404 ccf::jsgov::ProposalInfo proposal_info;
405 std::optional<std::string> constitution;
406
407 // Construct proposal_id, as digest of request and root
408 ProposalId proposal_id;
409 std::vector<uint8_t> request_digest;
410 {
411 auto root_at_read = ctx.tx.get_root_at_read_version();
412 if (!root_at_read.has_value())
413 {
414 detail::set_gov_error(
415 ctx.rpc_ctx,
416 HTTP_STATUS_INTERNAL_SERVER_ERROR,
417 ccf::errors::InternalError,
418 "Proposal failed to bind to state.");
419 return;
420 }
421
423 hasher->update_hash(root_at_read.value().h);
424
425 request_digest = ccf::crypto::sha256(
426 cose_ident.signature.data(), cose_ident.signature.size());
427
428 hasher->update_hash(request_digest);
429
430 const ccf::crypto::Sha256Hash proposal_hash = hasher->finalise();
431 proposal_id = proposal_hash.hex_str();
432 }
433
434 // Validate proposal, by calling into JS constitution
435 {
436 constitution =
437 ctx.tx.template ro<ccf::Constitution>(ccf::Tables::CONSTITUTION)
438 ->get();
439 if (!constitution.has_value())
440 {
441 detail::set_gov_error(
442 ctx.rpc_ctx,
443 HTTP_STATUS_INTERNAL_SERVER_ERROR,
444 ccf::errors::InternalError,
445 "No constitution is set - proposals cannot be evaluated");
446 return;
447 }
448
450
451 auto validate_func = context.get_exported_function(
452 constitution.value(),
453 "validate",
454 fmt::format("{}[0]", ccf::Tables::CONSTITUTION));
455
456 auto proposal_arg = context.new_string_len(cose_ident.content);
457 auto validate_result = context.call_with_rt_options(
458 validate_func,
459 {proposal_arg},
460 ctx.tx.template ro<ccf::JSEngine>(ccf::Tables::JSENGINE)->get(),
462
463 // Handle error cases of validation
464 {
465 if (validate_result.is_exception())
466 {
467 auto [reason, trace] = context.error_message();
468 if (context.interrupt_data.request_timed_out)
469 {
470 reason = "Operation took too long to complete.";
471 }
472 detail::set_gov_error(
473 ctx.rpc_ctx,
474 HTTP_STATUS_INTERNAL_SERVER_ERROR,
475 ccf::errors::InternalError,
476 fmt::format(
477 "Failed to execute validation: {} {}",
478 reason,
479 trace.value_or("")));
480 return;
481 }
482
483 if (!validate_result.is_obj())
484 {
485 detail::set_gov_error(
486 ctx.rpc_ctx,
487 HTTP_STATUS_INTERNAL_SERVER_ERROR,
488 ccf::errors::InternalError,
489 "Validation failed to return an object");
490 return;
491 }
492
493 std::string description;
494 auto desc = validate_result["description"];
495 if (desc.is_str())
496 {
497 description = context.to_str(desc).value_or("");
498 }
499
500 auto valid = validate_result["valid"];
501 if (!valid.is_true())
502 {
503 detail::set_gov_error(
504 ctx.rpc_ctx,
505 HTTP_STATUS_BAD_REQUEST,
506 ccf::errors::ProposalFailedToValidate,
507 fmt::format("Proposal failed to validate: {}", description));
508 return;
509 }
510 }
511
512 // Write proposal to KV
513 {
514 auto proposals_handle =
515 ctx.tx.template rw<ccf::jsgov::ProposalMap>(
516 jsgov::Tables::PROPOSALS);
517 // Introduce a read dependency, so that if identical proposal
518 // creations are in-flight and reading at the same version, all
519 // except the first conflict and are re-executed. If we ever
520 // produce a proposal ID which already exists, we must have a
521 // hash collision.
522 if (proposals_handle->has(proposal_id))
523 {
524 detail::set_gov_error(
525 ctx.rpc_ctx,
526 HTTP_STATUS_INTERNAL_SERVER_ERROR,
527 ccf::errors::InternalError,
528 "Proposal ID collision.");
529 return;
530 }
531 proposals_handle->put(
532 proposal_id, {proposal_body.begin(), proposal_body.end()});
533
534 auto proposal_info_handle =
535 ctx.tx.template wo<ccf::jsgov::ProposalInfoMap>(
536 jsgov::Tables::PROPOSALS_INFO);
537
538 proposal_info.proposer_id = cose_ident.member_id;
539 proposal_info.state = ccf::ProposalState::OPEN;
540
541 proposal_info_handle->put(proposal_id, proposal_info);
542
544 ctx.tx, cose_ident.member_id, cose_ident.envelope);
545 }
546 }
547
548 // Validate proposal's created_at time
549 {
550 // created_at, submitted as a binary integer number of seconds
551 // since epoch in the COSE Sign1 envelope, is converted to a
552 // decimal representation in ASCII, stored as a string, and
553 // compared alphanumerically. This is partly to keep governance as
554 // text-based as possible, to faciliate audit, but also to be able
555 // to benefit from future planned ordering support in the KV. To
556 // compare correctly, the string representation needs to be padded
557 // with leading zeroes, and must therefore not exceed a fixed
558 // digit width. 10 digits is enough to last until November 2286,
559 // ie. long enough.
560 if (cose_ident.protected_header.gov_msg_created_at > 9'999'999'999)
561 {
562 detail::set_gov_error(
563 ctx.rpc_ctx,
564 HTTP_STATUS_BAD_REQUEST,
565 ccf::errors::InvalidCreatedAt,
566 "Header parameter created_at value is too large");
567 return;
568 }
569
570 const auto created_at_str = fmt::format(
571 "{:0>10}", cose_ident.protected_header.gov_msg_created_at);
572
573 ccf::ProposalId colliding_proposal_id;
574 std::string min_created_at;
575
576 const auto subtime_result =
578 ctx.tx, created_at_str, request_digest, proposal_id);
579 switch (subtime_result.status)
580 {
582 {
583 detail::set_gov_error(
584 ctx.rpc_ctx,
585 HTTP_STATUS_BAD_REQUEST,
586 ccf::errors::ProposalCreatedTooLongAgo,
587 fmt::format(
588 "Proposal created too long ago, created_at must be greater "
589 "than {}",
590 subtime_result.info));
591 return;
592 }
593
595 {
596 detail::set_gov_error(
597 ctx.rpc_ctx,
598 HTTP_STATUS_BAD_REQUEST,
599 ccf::errors::ProposalReplay,
600 fmt::format(
601 "Proposal submission replay, already exists as proposal {}",
602 subtime_result.info));
603 return;
604 }
605
607 {
608 break;
609 }
610
611 default:
612 {
613 throw std::runtime_error(
614 "Invalid ProposalSubmissionResult::Status value");
615 }
616 }
617 }
618
619 // Resolve proposal (may pass immediately)
620 {
622 node_context,
623 network,
624 ctx.tx,
625 proposal_id,
626 proposal_body,
627 proposal_info,
628 constitution.value());
629
630 if (proposal_info.state == ProposalState::FAILED)
631 {
632 // If the proposal failed to apply, we want to discard the tx and
633 // not apply its side-effects to the KV state, because it may have
634 // failed mid-execution (eg - thrown an exception), in which case
635 // we do not want to apply partial writes. Note this differs from
636 // a failure that happens after a vote, in that this proposal is
637 // not recorded in the KV at all.
638 detail::set_gov_error(
639 ctx.rpc_ctx,
640 HTTP_STATUS_INTERNAL_SERVER_ERROR,
641 ccf::errors::InternalError,
642 fmt::format("{}", proposal_info.failure));
643 return;
644 }
645
646 const auto response_body = detail::convert_proposal_to_api_format(
647 proposal_id, proposal_info);
648
649 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
650 return;
651 }
652 }
653 }
654 };
655 registry
657 "/members/proposals:create",
658 HTTP_POST,
659 api_version_adapter(create_proposal),
661 .set_openapi_hidden(true)
662 .install();
663
664 auto withdraw_proposal = [&](auto& ctx, ApiVersion api_version) {
665 switch (api_version)
666 {
668 case ApiVersion::v1:
669 default:
670 {
671 const auto& cose_ident =
672 ctx.template get_caller<ccf::MemberCOSESign1AuthnIdentity>();
673 ccf::ProposalId proposal_id;
674
676 cose_ident, ctx.rpc_ctx, proposal_id))
677 {
678 return;
679 }
680
681 auto proposal_info_handle =
682 ctx.tx.template rw<ccf::jsgov::ProposalInfoMap>(
683 jsgov::Tables::PROPOSALS_INFO);
684
685 // Check proposal exists
686 auto proposal_info = proposal_info_handle->get(proposal_id);
687 if (!proposal_info.has_value())
688 {
689 // If it doesn't, then withdrawal is idempotent - we don't know if
690 // this previously existed or not, was withdrawn or accepted, but
691 // return a 204
692 ctx.rpc_ctx->set_response_status(HTTP_STATUS_NO_CONTENT);
693 return;
694 }
695
696 // Check caller is proposer
697 const auto member_id = cose_ident.member_id;
698 if (member_id != proposal_info->proposer_id)
699 {
700 detail::set_gov_error(
701 ctx.rpc_ctx,
702 HTTP_STATUS_FORBIDDEN,
703 ccf::errors::AuthorizationFailed,
704 fmt::format(
705 "Proposal {} can only be withdrawn by proposer {}, not caller "
706 "{}.",
707 proposal_id,
708 proposal_info->proposer_id,
709 member_id));
710 return;
711 }
712
713 // If proposal is still known, and state is neither OPEN nor
714 // WITHDRAWN, return an error - caller has done something wrong
715 if (
716 proposal_info->state != ProposalState::OPEN &&
717 proposal_info->state != ProposalState::WITHDRAWN)
718 {
719 detail::set_gov_error(
720 ctx.rpc_ctx,
721 HTTP_STATUS_BAD_REQUEST,
722 ccf::errors::ProposalNotOpen,
723 fmt::format(
724 "Proposal {} is currently in state {} and cannot be withdrawn.",
725 proposal_id,
726 proposal_info->state));
727 return;
728 }
729
730 // Check proposal is open - only write withdrawal if currently
731 // open
732 if (proposal_info->state == ProposalState::OPEN)
733 {
734 proposal_info->state = ProposalState::WITHDRAWN;
735 proposal_info_handle->put(proposal_id, proposal_info.value());
736
739 ctx.tx, cose_ident.member_id, cose_ident.envelope);
740 }
741
742 auto response_body = detail::convert_proposal_to_api_format(
743 proposal_id, proposal_info.value());
744
745 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
746 return;
747 }
748 }
749 };
750 registry
752 "/members/proposals/{proposalId}:withdraw",
753 HTTP_POST,
754 api_version_adapter(withdraw_proposal),
756 .set_openapi_hidden(true)
757 .install();
758
759 auto get_proposal = [&](auto& ctx, ApiVersion api_version) {
760 switch (api_version)
761 {
763 case ApiVersion::v1:
764 default:
765 {
766 ccf::ProposalId proposal_id;
767 if (!detail::try_parse_proposal_id(ctx.rpc_ctx, proposal_id))
768 {
769 return;
770 }
771
772 auto proposal_info_handle =
773 ctx.tx.template ro<ccf::jsgov::ProposalInfoMap>(
774 jsgov::Tables::PROPOSALS_INFO);
775 auto proposal_info = proposal_info_handle->get(proposal_id);
776 if (!proposal_info.has_value())
777 {
778 detail::set_gov_error(
779 ctx.rpc_ctx,
780 HTTP_STATUS_NOT_FOUND,
781 ccf::errors::ProposalNotFound,
782 fmt::format("Could not find proposal {}.", proposal_id));
783 return;
784 }
785
786 auto response_body = detail::convert_proposal_to_api_format(
787 proposal_id, proposal_info.value());
788
789 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
790 return;
791 }
792 }
793 };
794 registry
796 "/members/proposals/{proposalId}",
797 HTTP_GET,
798 api_version_adapter(get_proposal),
799 no_auth_required)
800 .set_openapi_hidden(true)
801 .install();
802
803 auto list_proposals = [&](auto& ctx, ApiVersion api_version) {
804 switch (api_version)
805 {
807 case ApiVersion::v1:
808 default:
809 {
810 auto proposal_info_handle =
811 ctx.tx.template ro<ccf::jsgov::ProposalInfoMap>(
812 jsgov::Tables::PROPOSALS_INFO);
813
814 auto proposal_list = nlohmann::json::array();
815 proposal_info_handle->foreach(
816 [&proposal_list](
817 const auto& proposal_id, const auto& proposal_info) {
818 auto api_proposal = detail::convert_proposal_to_api_format(
819 proposal_id, proposal_info);
820 proposal_list.push_back(api_proposal);
821 return true;
822 });
823
824 auto response_body = nlohmann::json::object();
825 response_body["value"] = proposal_list;
826
827 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
828 return;
829 }
830 }
831 };
832 registry
834 "/members/proposals",
835 HTTP_GET,
836 api_version_adapter(list_proposals),
837 no_auth_required)
838 .set_openapi_hidden(true)
839 .install();
840
841 auto get_actions = [&](auto& ctx, ApiVersion api_version) {
842 switch (api_version)
843 {
845 case ApiVersion::v1:
846 default:
847 {
848 ccf::ProposalId proposal_id;
849 if (!detail::try_parse_proposal_id(ctx.rpc_ctx, proposal_id))
850 {
851 return;
852 }
853
854 auto proposal_handle = ctx.tx.template ro<ccf::jsgov::ProposalMap>(
855 jsgov::Tables::PROPOSALS);
856
857 const auto proposal = proposal_handle->get(proposal_id);
858 if (!proposal.has_value())
859 {
860 detail::set_gov_error(
861 ctx.rpc_ctx,
862 HTTP_STATUS_NOT_FOUND,
863 ccf::errors::ProposalNotFound,
864 fmt::format("Could not find proposal {}.", proposal_id));
865 return;
866 }
867
868 ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
869 ctx.rpc_ctx->set_response_body(proposal.value());
870 return;
871 break;
872 }
873 }
874 };
875 registry
877 "/members/proposals/{proposalId}/actions",
878 HTTP_GET,
879 api_version_adapter(get_actions),
880 no_auth_required)
881 .set_openapi_hidden(true)
882 .install();
883
885 auto submit_ballot =
886 [&](ccf::endpoints::EndpointContext& ctx, ApiVersion api_version) {
887 switch (api_version)
888 {
890 case ApiVersion::v1:
891 default:
892 {
893 const auto& cose_ident =
894 ctx.template get_caller<ccf::MemberCOSESign1AuthnIdentity>();
895
896 ccf::ProposalId proposal_id;
898 cose_ident, ctx.rpc_ctx, proposal_id))
899 {
900 return;
901 }
902
903 ccf::MemberId member_id;
905 cose_ident, ctx.rpc_ctx, member_id))
906 {
907 return;
908 }
909
910 // Look up proposal info and check expected state
911 auto proposal_info_handle =
912 ctx.tx.template rw<ccf::jsgov::ProposalInfoMap>(
913 jsgov::Tables::PROPOSALS_INFO);
914 auto proposal_info = proposal_info_handle->get(proposal_id);
915 if (!proposal_info.has_value())
916 {
917 detail::set_gov_error(
918 ctx.rpc_ctx,
919 HTTP_STATUS_NOT_FOUND,
920 ccf::errors::ProposalNotFound,
921 fmt::format("Could not find proposal {}.", proposal_id));
922 return;
923 }
924
925 if (proposal_info->state != ccf::ProposalState::OPEN)
926 {
927 detail::set_gov_error(
928 ctx.rpc_ctx,
929 HTTP_STATUS_BAD_REQUEST,
930 ccf::errors::ProposalNotOpen,
931 fmt::format(
932 "Proposal {} is currently in state {} - only {} proposals "
933 "can receive votes",
934 proposal_id,
935 proposal_info->state,
937 return;
938 }
939
940 // Look up proposal contents
941 auto proposals_handle = ctx.tx.template ro<ccf::jsgov::ProposalMap>(
942 ccf::jsgov::Tables::PROPOSALS);
943 const auto proposal = proposals_handle->get(proposal_id);
944 if (!proposal.has_value())
945 {
946 detail::set_gov_error(
947 ctx.rpc_ctx,
948 HTTP_STATUS_NOT_FOUND,
949 ccf::errors::ProposalNotFound,
950 fmt::format("Could not find proposal {}.", proposal_id));
951 return;
952 }
953
954 // Parse and validate incoming ballot
955 const auto params = nlohmann::json::parse(cose_ident.content);
956 const auto ballot_it = params.find("ballot");
957 if (ballot_it == params.end() || !ballot_it.value().is_string())
958 {
959 detail::set_gov_error(
960 ctx.rpc_ctx,
961 HTTP_STATUS_BAD_REQUEST,
962 ccf::errors::InvalidInput,
963 "Signed request body is not a JSON object containing required "
964 "string field \"ballot\"");
965 return;
966 }
967
968 // Access constitution to evaluate ballots
969 const auto constitution =
970 ctx.tx.template ro<ccf::Constitution>(ccf::Tables::CONSTITUTION)
971 ->get();
972 if (!constitution.has_value())
973 {
974 detail::set_gov_error(
975 ctx.rpc_ctx,
976 HTTP_STATUS_INTERNAL_SERVER_ERROR,
977 ccf::errors::InternalError,
978 "No constitution is set - ballots cannot be evaluated");
979 return;
980 }
981
982 const auto ballot = ballot_it.value().get<std::string>();
983
984 const auto info_ballot_it = proposal_info->ballots.find(member_id);
985 if (info_ballot_it != proposal_info->ballots.end())
986 {
987 // If ballot matches previously submitted, aim for idempotent
988 // matching response
989 if (info_ballot_it->second == ballot)
990 {
991 const auto response_body =
993 proposal_id, proposal_info.value());
994
995 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
996 return;
997 }
998 else
999 {
1000 detail::set_gov_error(
1001 ctx.rpc_ctx,
1002 HTTP_STATUS_BAD_REQUEST,
1003 ccf::errors::VoteAlreadyExists,
1004 fmt::format(
1005 "Different ballot already submitted by {} for {}.",
1006 member_id,
1007 proposal_id));
1008 return;
1009 }
1010 }
1011
1012 // Store newly provided ballot
1013 proposal_info->ballots.insert_or_assign(
1014 info_ballot_it, member_id, ballot_it.value().get<std::string>());
1015
1017 ctx.tx, cose_ident.member_id, cose_ident.envelope);
1018
1020 node_context,
1021 network,
1022 ctx.tx,
1023 proposal_id,
1024 proposal.value(),
1025 proposal_info.value(),
1026 constitution.value());
1027
1028 if (proposal_info->state == ProposalState::FAILED)
1029 {
1030 // If the proposal failed to apply, we want to discard the tx and
1031 // not apply its side-effects to the KV state, because it may have
1032 // failed mid-execution (eg - thrown an exception), in which case
1033 // we do not want to apply partial writes
1034 detail::set_gov_error(
1035 ctx.rpc_ctx,
1036 HTTP_STATUS_INTERNAL_SERVER_ERROR,
1037 ccf::errors::InternalError,
1038 fmt::format("{}", proposal_info->failure));
1039 return;
1040 }
1041
1042 const auto response_body = detail::convert_proposal_to_api_format(
1043 proposal_id, proposal_info.value());
1044
1045 ctx.rpc_ctx->set_response_json(response_body, HTTP_STATUS_OK);
1046 return;
1047 }
1048 }
1049 };
1050 registry
1052 "/members/proposals/{proposalId}/ballots/{memberId}:submit",
1053 HTTP_POST,
1054 api_version_adapter(submit_ballot),
1056 .set_openapi_hidden(true)
1057 .install();
1058
1059 auto get_ballot = [&](auto& ctx, ApiVersion api_version) {
1060 switch (api_version)
1061 {
1063 case ApiVersion::v1:
1064 default:
1065 {
1066 ccf::ProposalId proposal_id;
1067 if (!detail::try_parse_proposal_id(ctx.rpc_ctx, proposal_id))
1068 {
1069 return;
1070 }
1071
1072 ccf::MemberId member_id;
1073 if (!detail::try_parse_member_id(ctx.rpc_ctx, member_id))
1074 {
1075 return;
1076 }
1077
1078 // Look up proposal
1079 auto proposal_info_handle =
1080 ctx.tx.template ro<ccf::jsgov::ProposalInfoMap>(
1081 ccf::jsgov::Tables::PROPOSALS_INFO);
1082
1083 // NB: Logically constant (read-only), but non-const so we can
1084 // eventually move a field out
1085 auto proposal_info = proposal_info_handle->get(proposal_id);
1086 if (!proposal_info.has_value())
1087 {
1088 detail::set_gov_error(
1089 ctx.rpc_ctx,
1090 HTTP_STATUS_NOT_FOUND,
1091 ccf::errors::ProposalNotFound,
1092 fmt::format("Proposal {} does not exist.", proposal_id));
1093 return;
1094 }
1095
1096 // Look up ballot
1097 auto ballot_it = proposal_info->ballots.find(member_id);
1098 if (ballot_it == proposal_info->ballots.end())
1099 {
1100 detail::set_gov_error(
1101 ctx.rpc_ctx,
1102 HTTP_STATUS_NOT_FOUND,
1103 ccf::errors::VoteNotFound,
1104 fmt::format(
1105 "Member {} has not voted for proposal {}.",
1106 member_id,
1107 proposal_id));
1108 return;
1109 }
1110
1111 // Return the raw ballot, with appropriate content-type
1112 ctx.rpc_ctx->set_response_status(HTTP_STATUS_OK);
1113 ctx.rpc_ctx->set_response_body(std::move(ballot_it->second));
1114 ctx.rpc_ctx->set_response_header(
1115 http::headers::CONTENT_TYPE,
1116 http::headervalues::contenttype::JAVASCRIPT);
1117 return;
1118 }
1119 }
1120 };
1121 registry
1123 "/members/proposals/{proposalId}/ballots/{memberId}",
1124 HTTP_GET,
1125 api_version_adapter(get_ballot),
1126 no_auth_required)
1127 .set_openapi_hidden(true)
1128 .install();
1129 }
1130}
Definition gov_effects_interface.h:12
Definition base_endpoint_registry.h:121
Definition sha256_hash.h:16
std::string hex_str() const
Definition sha256_hash.cpp:61
virtual Endpoint make_endpoint(const std::string &method, RESTVerb verb, const EndpointFunction &f, const AuthnPolicies &ap)
Definition endpoint_registry.cpp:204
virtual Endpoint make_read_only_endpoint(const std::string &method, RESTVerb verb, const ReadOnlyEndpointFunction &f, const AuthnPolicies &ap)
Definition endpoint_registry.cpp:235
Definition common_context.h:54
M::ReadOnlyHandle * ro(M &m)
Definition tx.h:169
Definition tx.h:201
M::Handle * rw(M &m)
Definition tx.h:212
M::WriteOnlyHandle * wo(M &m)
Definition tx.h:233
Definition map.h:30
Definition value.h:32
HashBytes sha256(const std::span< uint8_t const > &data)
Definition hash.cpp:24
std::shared_ptr< ISha256Hash > make_incremental_sha256()
Definition hash.cpp:46
bool try_parse_member_id(const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::MemberId &member_id)
Definition helpers.h:63
void record_cose_governance_history(ccf::kv::Tx &tx, const MemberId &caller_id, const std::span< const uint8_t > &cose_sign1)
Definition proposals.h:95
bool try_parse_proposal_id(const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::ProposalId &proposal_id)
Definition helpers.h:157
void remove_all_other_non_open_proposals(ccf::kv::Tx &tx, const ProposalId &proposal_id)
Definition proposals.h:106
nlohmann::json convert_proposal_to_api_format(const ProposalId &proposal_id, const ccf::jsgov::ProposalInfo &summary)
Definition proposals.h:345
void resolve_proposal(ccf::AbstractNodeContext &context, ccf::NetworkState &network, ccf::kv::Tx &tx, const ProposalId &proposal_id, const std::span< const uint8_t > &proposal_bytes, ccf::jsgov::ProposalInfo &proposal_info, const std::string &constitution)
Definition proposals.h:131
bool try_parse_signed_member_id(const ccf::MemberCOSESign1AuthnIdentity &cose_ident, const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::MemberId &member_id)
Definition helpers.h:99
bool try_parse_signed_proposal_id(const ccf::MemberCOSESign1AuthnIdentity &cose_ident, const std::shared_ptr< ccf::RpcContext > &rpc_ctx, ccf::ProposalId &proposal_id)
Definition helpers.h:197
ProposalSubmissionResult validate_proposal_submission_time(ccf::kv::Tx &tx, const std::string &created_at, const std::vector< uint8_t > &request_digest, const ccf::ProposalId &proposal_id)
Definition proposals.h:31
AuthnPolicies active_member_sig_only_policies(const std::string &gov_msg_type)
Definition helpers.h:16
Definition api_version.h:11
void init_proposals_handlers(ccf::BaseEndpointRegistry &registry, NetworkState &network, ccf::AbstractNodeContext &node_context)
Definition proposals.h:387
auto api_version_adapter(Fn &&f, ApiVersion min_accepted=ApiVersion::MIN)
Definition api_version.h:101
ApiVersion
Definition api_version.h:13
std::unordered_map< ccf::MemberId, bool > Votes
Definition gov.h:14
std::unordered_map< ccf::MemberId, Failure > VoteFailures
Definition gov.h:32
std::string ProposalId
Definition proposals.h:40
Definition node_context.h:12
std::shared_ptr< T > get_subsystem(const std::string &name) const
Definition node_context.h:37
Value & value()
Definition entity_id.h:60
Definition network_state.h:12
std::shared_ptr< ccf::RpcContext > rpc_ctx
Definition endpoint_context.h:31
Definition endpoint_context.h:55
ccf::kv::Tx & tx
Definition endpoint_context.h:61
Endpoint & set_openapi_hidden(bool hidden)
Definition endpoint.cpp:10
void install()
Definition endpoint.cpp:122
std::string info
Definition proposals.h:28
enum ccf::gov::endpoints::detail::ProposalSubmissionResult::Status status
Definition gov.h:17
Proposal metadata stored in the KV.
Definition gov.h:36
std::optional< Votes > final_votes
Definition gov.h:50
ccf::MemberId proposer_id
ID of the member who originally created/submitted this proposal.
Definition gov.h:38
std::optional< Failure > failure
Definition gov.h:58
std::optional< VoteFailures > vote_failures
Definition gov.h:54
Ballots ballots
Definition gov.h:45
ccf::ProposalState state
Current state of this proposal (eg - open, accepted, withdrawn)
Definition gov.h:40