Skip to content

Commit d2132fb

Browse files
authored
Add ballotSubmitters field to proposal API responses (#7840)
1 parent acbde65 commit d2132fb

7 files changed

Lines changed: 99 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
66
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
77

8+
## [7.0.2]
9+
10+
[7.0.2]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.2
11+
12+
### Added
13+
14+
- The governance proposal endpoints now include a `ballotSubmitters` field in their responses, containing the list of member IDs that have submitted a ballot for the proposal (#7840).
15+
816
## [7.0.1]
917

1018
[7.0.1]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.1

doc/schemas/gov/2023-06-01-preview/gov.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,13 @@
13821382
"$ref": "#/definitions/safeuint",
13831383
"description": "Count of how many ballots have been submitted for this proposal."
13841384
},
1385+
"ballotSubmitters": {
1386+
"type": "array",
1387+
"items": {
1388+
"$ref": "#/definitions/memberId"
1389+
},
1390+
"description": "List of member IDs who have submitted a ballot for this proposal."
1391+
},
13851392
"finalVotes": {
13861393
"$ref": "#/definitions/Proposals.MemberVotes",
13871394
"description": "If a proposal is not open, then this contains the result of each ballot when the proposal transitioned from open."
@@ -1399,7 +1406,8 @@
13991406
"proposalId",
14001407
"proposerId",
14011408
"proposalState",
1402-
"ballotCount"
1409+
"ballotCount",
1410+
"ballotSubmitters"
14031411
]
14041412
},
14051413
"Proposals.ProposalActions": {

doc/schemas/gov/2024-07-01/gov.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1494,6 +1494,13 @@
14941494
"$ref": "#/definitions/safeuint",
14951495
"description": "Count of how many ballots have been submitted for this proposal."
14961496
},
1497+
"ballotSubmitters": {
1498+
"type": "array",
1499+
"items": {
1500+
"$ref": "#/definitions/memberId"
1501+
},
1502+
"description": "List of member IDs who have submitted a ballot for this proposal."
1503+
},
14971504
"finalVotes": {
14981505
"$ref": "#/definitions/Proposals.MemberVotes",
14991506
"description": "If a proposal is not open, then this contains the result of each ballot when the proposal transitioned from open."
@@ -1511,7 +1518,8 @@
15111518
"proposalId",
15121519
"proposerId",
15131520
"proposalState",
1514-
"ballotCount"
1521+
"ballotCount",
1522+
"ballotSubmitters"
15151523
]
15161524
},
15171525
"Proposals.ProposalActions": {

python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "ccf"
7-
version = "7.0.1"
7+
version = "7.0.2"
88
authors = [
99
{ name="CCF Team", email="CCF-Sec@microsoft.com" },
1010
]

src/node/gov/handlers/proposals.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,19 @@ namespace ccf::gov::endpoints
358358
response_body["proposalState"] = summary.state;
359359
response_body["ballotCount"] = summary.ballots.size();
360360

361+
auto ballot_submitters = nlohmann::json::array();
362+
std::vector<ccf::MemberId> submitter_ids;
363+
for (const auto& [member_id, _] : summary.ballots)
364+
{
365+
submitter_ids.push_back(member_id);
366+
}
367+
std::sort(submitter_ids.begin(), submitter_ids.end());
368+
for (const auto& member_id : submitter_ids)
369+
{
370+
ballot_submitters.push_back(member_id);
371+
}
372+
response_body["ballotSubmitters"] = ballot_submitters;
373+
361374
std::optional<ccf::jsgov::Votes> votes = summary.final_votes;
362375

363376
if (votes.has_value())

tests/governance_js.py

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ def test_proposal_storage(network, args):
261261
"proposalState": "Open",
262262
"proposalId": proposal_id,
263263
"ballotCount": 0,
264+
"ballotSubmitters": [],
264265
}
265266
assert r.body.json() == expected, r.body.json()
266267

@@ -306,6 +307,7 @@ def test_proposal_withdrawal(network, args):
306307
"proposalState": "Open",
307308
"proposalId": proposal_id,
308309
"ballotCount": 0,
310+
"ballotSubmitters": [],
309311
}
310312
assert r.body.json() == expected, r.body.json()
311313

@@ -316,6 +318,7 @@ def test_proposal_withdrawal(network, args):
316318
"proposalState": "Withdrawn",
317319
"proposalId": proposal_id,
318320
"ballotCount": 0,
321+
"ballotSubmitters": [],
319322
}
320323
assert r.body.json() == expected, r.body.json()
321324

@@ -405,6 +408,8 @@ def test_pure_proposals(network, args):
405408
r = c.post("/gov/members/proposals:create", prop)
406409
assert r.status_code == 200, r.body.text()
407410
assert r.body.json()["proposalState"] == state, r.body.json()
411+
assert "finalVotes" in r.body.json(), r.body.json()
412+
assert r.body.json()["finalVotes"] == {}, r.body.json()
408413
proposal_id = r.body.json()["proposalId"]
409414

410415
ballot = ballot_yes
@@ -568,6 +573,10 @@ def test_proposals_with_votes(network, args):
568573
)
569574
assert r.status_code == 200, r.body.text()
570575
assert r.body.json()["proposalState"] == state, r.body.json()
576+
assert "finalVotes" in r.body.json(), r.body.json()
577+
assert r.body.json()["finalVotes"] == {
578+
member_id: direction == "true"
579+
}, r.body.json()
571580

572581
infra.clients.get_clock().advance()
573582

@@ -585,6 +594,10 @@ def test_proposals_with_votes(network, args):
585594
)
586595
assert r.status_code == 200, r.body.text()
587596
assert r.body.json()["proposalState"] == state, r.body.json()
597+
assert "finalVotes" in r.body.json(), r.body.json()
598+
assert r.body.json()["finalVotes"] == {
599+
member_id: direction == "true"
600+
}, r.body.json()
588601

589602
for prop, state, ballot in [
590603
(always_accept_with_two_votes, "Accepted", ballot_yes),
@@ -593,6 +606,7 @@ def test_proposals_with_votes(network, args):
593606
r = c.post("/gov/members/proposals:create", prop)
594607
assert r.status_code == 200, r.body.text()
595608
assert r.body.json()["proposalState"] == "Open", r.body.json()
609+
assert r.body.json()["ballotSubmitters"] == [], r.body.json()
596610
proposal_id = r.body.json()["proposalId"]
597611

598612
r = c.post(
@@ -601,6 +615,7 @@ def test_proposals_with_votes(network, args):
601615
)
602616
assert r.status_code == 200, r.body.text()
603617
assert r.body.json()["proposalState"] == "Open", r.body.json()
618+
assert r.body.json()["ballotSubmitters"] == [member_id], r.body.json()
604619

605620
with node.api_versioned_client(
606621
None, None, "member1", api_version=args.gov_api_version
@@ -614,6 +629,16 @@ def test_proposals_with_votes(network, args):
614629
)
615630
assert r.status_code == 200, r.body.text()
616631
assert r.body.json()["proposalState"] == state, r.body.json()
632+
assert set(r.body.json()["ballotSubmitters"]) == {
633+
member_id,
634+
other_member_id,
635+
}, r.body.json()
636+
assert "finalVotes" in r.body.json(), r.body.json()
637+
expected_vote = state == "Accepted"
638+
assert r.body.json()["finalVotes"] == {
639+
member_id: expected_vote,
640+
other_member_id: expected_vote,
641+
}, r.body.json()
617642

618643
return network
619644

@@ -640,34 +665,38 @@ def test_vote_failure_reporting(network, args):
640665
with node.api_versioned_client(
641666
None, None, "member0", api_version=args.gov_api_version
642667
) as c:
643-
member_id = network.consortium.get_member_by_local_id("member0").service_id
668+
member0_id = network.consortium.get_member_by_local_id("member0").service_id
644669
r = c.post("/gov/members/proposals:create", always_accept_with_one_vote)
645670
assert r.status_code == 200, r.body.text()
646671
assert r.body.json()["proposalState"] == "Open", r.body.json()
672+
assert r.body.json()["ballotSubmitters"] == [], r.body.json()
647673
proposal_id = r.body.json()["proposalId"]
648674

649675
ballot = vote(f'throw new Error("{error_body}")')
650676
r = c.post(
651-
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
677+
f"/gov/members/proposals/{proposal_id}/ballots/{member0_id}:submit", ballot
652678
)
653679
assert r.status_code == 200, r.body.text()
654680
assert r.body.json()["proposalState"] == "Open", r.body.json()
681+
assert r.body.json()["ballotSubmitters"] == [member0_id], r.body.json()
655682

656683
with node.api_versioned_client(
657684
None, None, "member1", api_version=args.gov_api_version
658685
) as c:
659686
ballot = ballot_yes
660-
member_id = network.consortium.get_member_by_local_id("member1").service_id
687+
member1_id = network.consortium.get_member_by_local_id("member1").service_id
661688
r = c.post(
662-
f"/gov/members/proposals/{proposal_id}/ballots/{member_id}:submit", ballot
689+
f"/gov/members/proposals/{proposal_id}/ballots/{member1_id}:submit", ballot
663690
)
664691
assert r.status_code == 200, r.body.text()
665692
rj = r.body.json()
666693
LOG.warning(rj)
667694
assert rj["proposalState"] == "Accepted", r.body.json()
695+
assert set(rj["ballotSubmitters"]) == {member0_id, member1_id}, rj
696+
assert "finalVotes" in rj, rj
697+
assert rj["finalVotes"] == {member1_id: True}, rj
668698
assert len(rj["voteFailures"]) == 1, rj["voteFailures"]
669-
member_id = network.consortium.get_member_by_local_id("member0").service_id
670-
assert rj["voteFailures"][member_id]["reason"] == f"Error: {error_body}", rj[
699+
assert rj["voteFailures"][member0_id]["reason"] == f"Error: {error_body}", rj[
671700
"voteFailures"
672701
]
673702

@@ -692,6 +721,7 @@ def test_operator_proposals_and_votes(network, args):
692721
)
693722
assert r.status_code == 200, r.body.text()
694723
assert r.body.json()["proposalState"] == "Accepted", r.body.json()
724+
assert r.body.json()["finalVotes"] == {member_id: True}, r.body.json()
695725

696726
r = c.post(
697727
"/gov/members/proposals:create", always_accept_if_proposed_by_operator
@@ -1392,10 +1422,20 @@ def test_final_proposal_visibility(network, args):
13921422
LOG.info("Confirm that finalVotes is present in submit-ballot response")
13931423
body = response.body.json()
13941424
assert "finalVotes" in body, body
1425+
assert set(body["ballotSubmitters"]) == {
1426+
booster.service_id,
1427+
turncoat.service_id,
1428+
fairweather.service_id,
1429+
}, body
13951430

13961431
LOG.info("Confirm that finalVotes is present in get-proposal response")
13971432
body = consortium.get_proposal_raw(primary, third.proposal_id)
13981433
assert "finalVotes" in body, body
1434+
assert set(body["ballotSubmitters"]) == {
1435+
booster.service_id,
1436+
turncoat.service_id,
1437+
fairweather.service_id,
1438+
}, body
13991439

14001440
LOG.info("Confirm that expected values were actually written to the KV")
14011441
# To avoid creating an extra endpoint in the app, we smuggle a read into a new

tests/infra/consortium.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,19 @@ def vote_using_majority(
317317
)
318318
raise infra.proposal.ProposalNotAccepted(proposal, response)
319319

320+
raw = self.get_proposal_raw(remote_node, proposal.proposal_id)
321+
assert (
322+
"finalVotes" in raw
323+
), f"Expected finalVotes field to be present, got: {raw}"
324+
final_votes = raw["finalVotes"]
325+
for voter_id in proposal.voters:
326+
assert (
327+
voter_id in final_votes
328+
), f"Voter {voter_id} not found in finalVotes: {final_votes}"
329+
assert (
330+
final_votes[voter_id] is True
331+
), f"Voter {voter_id} vote is not true: {final_votes[voter_id]}"
332+
320333
return proposal
321334

322335
def get_proposal_raw(self, remote_node, proposal_id):

0 commit comments

Comments
 (0)