diff --git a/API-CHANGELOG.md b/API-CHANGELOG.md index a04f2653285..3783e669ef9 100644 --- a/API-CHANGELOG.md +++ b/API-CHANGELOG.md @@ -28,6 +28,8 @@ This section contains changes targeting a future version. ### Additions +- `ledger_entry`, `account_objects`: Added the `Ballot` and `BallotVote` ledger entry types introduced by the `ConfidentialVoting` amendment. `ledger_entry` accepts a `ballot` request object (`owner` + `seq`) and a `ballot_vote` request object (`ballot_id` + `account`), or a hex object ID for either. `account_objects` returns these entries and accepts them as `type` filters. + - `account_tx`: Added an optional `delegate` request object to filter delegated transactions. The object requires `delegate_filter`, which must be either `actor` for transactions owned by the requested account but signed by another account, or `authorizer` for transactions signed by the requested account on behalf of another account. The optional `counter_party` account narrows the results to a specific signer/delegate for `actor` or a specific owner/delegator for `authorizer`. Malformed `delegate`, `delegate_filter`, and `counter_party` values return standard invalid field errors, and invalid account IDs return `actMalformed`. When paginating delegate-filtered queries, a marker from a delegate-filtered query includes a `delegate` flag and is only valid for follow-up requests that also supply `delegate` (mixing marker conventions returns `invalidParams`). Because filtering is applied after the ledger scan, a page may contain fewer results than `limit` (possibly zero) while still returning a marker, so callers must continue until no marker is present. diff --git a/include/xrpl/protocol/ConfidentialTransfer.h b/include/xrpl/protocol/ConfidentialTransfer.h index ecf7970aba0..3154b28d5f8 100644 --- a/include/xrpl/protocol/ConfidentialTransfer.h +++ b/include/xrpl/protocol/ConfidentialTransfer.h @@ -16,6 +16,7 @@ #include #include #include +#include namespace xrpl { @@ -433,4 +434,88 @@ verifyConvertBackProof( uint64_t amount, uint256 const& contextHash); +/** + * @brief Generates the context hash for a BallotCastVote transaction. + * + * Binds the cast's range proof to this specific transaction, preventing + * proof reuse across ballots or accounts. + * + * @param account The voter's account ID. + * @param ballotID The target ballot's ledger index. + * @param sequence The transaction sequence number or ticket number. + * @return A 256-bit context hash unique to this cast. + */ +uint256 +getBallotCastContextHash(AccountID const& account, uint256 const& ballotID, std::uint32_t sequence); + +/** + * @brief Generates the context hash for a BallotFinalize transaction. + * + * Binds each per-option decryption-correctness proof to this specific + * finalize transaction. + * + * @param account The tally authority's account ID. + * @param ballotID The target ballot's ledger index. + * @param sequence The transaction sequence number or ticket number. + * @return A 256-bit context hash unique to this finalize. + */ +uint256 +getBallotFinalizeContextHash( + AccountID const& account, + uint256 const& ballotID, + std::uint32_t sequence); + +/** + * @brief Verifies an aggregated Bulletproof range proof over ballot option + * commitments. + * + * Proves that every one of the N per-option values committed in + * @p commitments lies in the non-negative range, so a vote cannot subtract + * weight from a disliked option. Thin wrapper over + * mpt_verify_aggregated_bulletproof. + * + * @param proof The serialized aggregated Bulletproof. + * @param commitments One 33-byte Pedersen commitment per option. + * @param contextHash The 256-bit context hash binding the proof. + * @return tesSUCCESS if the proof is valid, or an error code otherwise. + */ +TER +verifyBallotRangeProof( + Slice const& proof, + std::vector const& commitments, + uint256 const& contextHash); + +/** + * @brief Verifies the ciphertext-commitment linkage for one ballot option. + * + * Proves that the option's ElGamal ciphertext(s) encrypt the same value that + * its Pedersen commitment commits to, and that every mirror ciphertext (tally, + * optional auditor, optional voter) encrypts that same value under shared + * randomness. Combined with the aggregated range proof over the commitment, + * this pins the tally update to a non-negative value the voter cannot forge — + * the verifiable-encryption guarantee a vote needs because it encrypts under + * the tally key, which the voter does not own. + * + * Wraps secp256k1_compact_standard_verify. The balance-linkage terms of that + * relation are neutralized with a canonical witness (sk_A = 1, rho_b = 1), so + * pk_A = G, PC_b = H and B1 = B2 = G are reconstructed identically here and by + * the prover, and only the 192-byte proof is carried on the wire. + * + * @param pubKeys The n mirror public keys, tally key first (33 bytes each). + * @param c1 The shared ElGamal C1 component (33 bytes). + * @param c2PerKey The n ElGamal C2 components, one per mirror key (33 bytes). + * @param commitment The option's Pedersen commitment PC_m (33 bytes). + * @param proof The 192-byte compact sigma linkage proof. + * @param contextHash The 256-bit context hash binding the proof. + * @return tesSUCCESS if the linkage holds, or an error code otherwise. + */ +TER +verifyBallotVoteLinkage( + std::vector const& pubKeys, + Slice const& c1, + std::vector const& c2PerKey, + Slice const& commitment, + Slice const& proof, + uint256 const& contextHash); + } // namespace xrpl diff --git a/include/xrpl/protocol/Indexes.h b/include/xrpl/protocol/Indexes.h index 07493da0bd6..887bb540edc 100644 --- a/include/xrpl/protocol/Indexes.h +++ b/include/xrpl/protocol/Indexes.h @@ -393,6 +393,30 @@ permissionedDomain(AccountID const& account, std::uint32_t seq) noexcept; Keylet permissionedDomain(uint256 const& domainID) noexcept; + +/** + * A ballot owned by `owner`, keyed by the creating transaction sequence. + */ +Keylet +ballot(AccountID const& owner, std::uint32_t seq) noexcept; + +inline Keylet +ballot(uint256 const& ballotID) +{ + return {ltBALLOT, ballotID}; +} + +/** + * A voter's cast on the ballot identified by `ballotID`. + */ +Keylet +ballotVote(uint256 const& ballotID, AccountID const& voter) noexcept; + +inline Keylet +ballotVote(uint256 const& key) +{ + return {ltBALLOT_VOTE, key}; +} } // namespace keylet // Everything below is deprecated and should be removed in favor of keylets: diff --git a/include/xrpl/protocol/LedgerFormats.h b/include/xrpl/protocol/LedgerFormats.h index 7c504f6bdd3..3af4c6b9b7d 100644 --- a/include/xrpl/protocol/LedgerFormats.h +++ b/include/xrpl/protocol/LedgerFormats.h @@ -219,7 +219,11 @@ enum LedgerEntryType : std::uint16_t { \ LEDGER_OBJECT(Sponsorship, \ LSF_FLAG(lsfSponsorshipRequireSignForFee, 0x00010000) \ - LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) + LSF_FLAG(lsfSponsorshipRequireSignForReserve, 0x00020000)) \ + \ + LEDGER_OBJECT(Ballot, \ + LSF_FLAG(lsfBallotFinalized, 0x00000001) /* True, results have been published */ \ + LSF_FLAG(lsfVoterRecoverable, 0x00000002)) /* True, casts carry a voter self-recovery vector */ // clang-format on diff --git a/include/xrpl/protocol/TER.h b/include/xrpl/protocol/TER.h index 730d0212545..4735dd30889 100644 --- a/include/xrpl/protocol/TER.h +++ b/include/xrpl/protocol/TER.h @@ -370,6 +370,10 @@ enum TECcodes : TERUnderlyingType { tecNO_DELEGATE_PERMISSION = 198, tecBAD_PROOF = 199, tecNO_SPONSOR_PERMISSION = 200, + tecBALLOT_CLOSED = 201, + tecBALLOT_VOTED = 202, + tecBALLOT_NOT_OPEN = 203, + tecBALLOT_EXISTS = 204, }; //------------------------------------------------------------------------------ diff --git a/include/xrpl/protocol/TxFlags.h b/include/xrpl/protocol/TxFlags.h index 0afdebb898a..d9be3c7534b 100644 --- a/include/xrpl/protocol/TxFlags.h +++ b/include/xrpl/protocol/TxFlags.h @@ -231,6 +231,10 @@ inline constexpr FlagValue tfUniversalMask = ~tfUniversal; TF_FLAG(tfSponsorshipEnd, 0x00010000) \ TF_FLAG(tfSponsorshipCreate, 0x00020000) \ TF_FLAG(tfSponsorshipReassign, 0x00040000), \ + MASK_ADJ(0)) \ + \ + TRANSACTION(BallotCreate, \ + TF_FLAG(tfVoterRecoverable, lsfVoterRecoverable), /* casts must carry a voter self-recovery vector */ \ MASK_ADJ(0)) // clang-format on diff --git a/include/xrpl/protocol/detail/features.macro b/include/xrpl/protocol/detail/features.macro index bfe03a63037..addb4a72cf4 100644 --- a/include/xrpl/protocol/detail/features.macro +++ b/include/xrpl/protocol/detail/features.macro @@ -15,6 +15,7 @@ // Add new amendments to the top of this list. // Keep it sorted in reverse chronological order. +XRPL_FEATURE(ConfidentialVoting, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(Sponsor, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(BatchV1_1, Supported::Yes, VoteBehavior::DefaultNo) XRPL_FEATURE(LendingProtocolV1_1, Supported::No, VoteBehavior::DefaultNo) diff --git a/include/xrpl/protocol/detail/ledger_entries.macro b/include/xrpl/protocol/detail/ledger_entries.macro index 90810e06d2d..3c80cee58b3 100644 --- a/include/xrpl/protocol/detail/ledger_entries.macro +++ b/include/xrpl/protocol/detail/ledger_entries.macro @@ -409,6 +409,7 @@ LEDGER_ENTRY(ltMPTOKEN_ISSUANCE, 0x007e, MPTokenIssuance, mpt_issuance, ({ {sfIssuerEncryptionKey, SoeOptional}, {sfAuditorEncryptionKey, SoeOptional}, {sfConfidentialOutstandingAmount, SoeDefault}, + {sfBallotID, SoeOptional}, // ConfidentialVoting: the issuance's open ballot, if any })) /** A ledger object which tracks MPToken @@ -428,6 +429,8 @@ LEDGER_ENTRY(ltMPTOKEN, 0x007f, MPToken, mptoken, ({ {sfIssuerEncryptedBalance, SoeOptional}, {sfAuditorEncryptedBalance, SoeOptional}, {sfHolderEncryptionKey, SoeOptional}, + {sfVoteLockedAmount, SoeOptional}, // ConfidentialVoting: locked while a ballot is open + {sfBallotID, SoeOptional}, // ConfidentialVoting: the ballot holding the lock })) /** A ledger object which tracks Oracle @@ -636,5 +639,47 @@ LEDGER_ENTRY(ltSPONSORSHIP, 0x0090, Sponsorship, sponsorship, ({ {sfSponseeNode, SoeRequired}, })) +/** A ledger object representing a confidential ballot with a homomorphically + encrypted tally. + + \sa keylet::ballot + */ +LEDGER_ENTRY(ltBALLOT, 0x0091, Ballot, ballot, ({ + {sfOwner, SoeRequired}, + {sfSequence, SoeRequired}, + {sfMPTokenIssuanceID, SoeOptional}, // token mode (one-of with sfDomainID) + {sfDomainID, SoeOptional}, // credential mode (one-of) + {sfDigest, SoeRequired}, // hash of the off-ledger ballot document + {sfURI, SoeOptional}, + {sfOptionCount, SoeRequired}, + {sfTallyPublicKey, SoeRequired}, + {sfAuditorEncryptionKey, SoeOptional}, // optional auditor ElGamal key + {sfEncryptedTally, SoeRequired}, // one ElGamal ciphertext per option + {sfOpenTime, SoeRequired}, + {sfCloseTime, SoeRequired}, + {sfVoteCount, SoeDefault}, + {sfResults, SoeOptional}, // plaintext counts, present after finalize + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + +/** A ledger object recording a single voter's cast on a Ballot. + + \sa keylet::ballotVote + */ +LEDGER_ENTRY(ltBALLOT_VOTE, 0x0092, BallotVote, ballot_vote, ({ + {sfAccount, SoeRequired}, // voter + {sfBallotID, SoeRequired}, + {sfBallotWeight, SoeRequired}, + {sfEncryptedVotes, SoeRequired}, // vote vector under the tally key + {sfAuditorEncryptedVotes, SoeOptional}, // present iff the ballot has an auditor key + {sfVoterPublicKey, SoeOptional}, // present iff lsfVoterRecoverable + {sfVoterEncryptedVotes, SoeOptional}, // present iff lsfVoterRecoverable + {sfOwnerNode, SoeRequired}, + {sfPreviousTxnID, SoeRequired}, + {sfPreviousTxnLgrSeq, SoeRequired}, +})) + #undef EXPAND #undef LEDGER_ENTRY_DUPLICATE diff --git a/include/xrpl/protocol/detail/sfields.macro b/include/xrpl/protocol/detail/sfields.macro index 4ef76c8b759..20fbfb944e8 100644 --- a/include/xrpl/protocol/detail/sfields.macro +++ b/include/xrpl/protocol/detail/sfields.macro @@ -25,6 +25,7 @@ TYPED_SFIELD(sfUNLModifyDisabling, UINT8, 17) TYPED_SFIELD(sfHookResult, UINT8, 18) TYPED_SFIELD(sfWasLockingChainSend, UINT8, 19) TYPED_SFIELD(sfWithdrawalPolicy, UINT8, 20) +TYPED_SFIELD(sfOptionCount, UINT8, 21) // 16-bit integers (common) TYPED_SFIELD(sfLedgerEntryType, UINT16, 1, SField::kSmdNever) @@ -119,6 +120,8 @@ TYPED_SFIELD(sfSponsoringOwnerCount, UINT32, 71) TYPED_SFIELD(sfSponsoringAccountCount, UINT32, 72) TYPED_SFIELD(sfRemainingOwnerCount, UINT32, 73) TYPED_SFIELD(sfSponsorFlags, UINT32, 74) +TYPED_SFIELD(sfOpenTime, UINT32, 75) +TYPED_SFIELD(sfVoteCount, UINT32, 76) // 64-bit integers (common) TYPED_SFIELD(sfIndexNext, UINT64, 1) @@ -154,6 +157,8 @@ TYPED_SFIELD(sfVaultNode, UINT64, 30) TYPED_SFIELD(sfLoanBrokerNode, UINT64, 31) TYPED_SFIELD(sfConfidentialOutstandingAmount, UINT64, 32, SField::kSmdBaseTen|SField::kSmdDefault) TYPED_SFIELD(sfSponseeNode, UINT64, 33) +TYPED_SFIELD(sfBallotWeight, UINT64, 34) +TYPED_SFIELD(sfVoteLockedAmount, UINT64, 35, SField::kSmdBaseTen|SField::kSmdDefault) // 128-bit TYPED_SFIELD(sfEmailHash, UINT128, 1) @@ -216,6 +221,7 @@ TYPED_SFIELD(sfLoanID, UINT256, 38) TYPED_SFIELD(sfReferenceHolding, UINT256, 39) TYPED_SFIELD(sfBlindingFactor, UINT256, 40) TYPED_SFIELD(sfObjectID, UINT256, 41) +TYPED_SFIELD(sfBallotID, UINT256, 42) // number (common) TYPED_SFIELD(sfNumber, NUMBER, 1) @@ -326,6 +332,9 @@ TYPED_SFIELD(sfAuditorEncryptedAmount, VL, 43) TYPED_SFIELD(sfAuditorEncryptionKey, VL, 44) TYPED_SFIELD(sfAmountCommitment, VL, 45) TYPED_SFIELD(sfBalanceCommitment, VL, 46) +TYPED_SFIELD(sfEncryptedVote, VL, 47) +TYPED_SFIELD(sfTallyPublicKey, VL, 48) +TYPED_SFIELD(sfVoterPublicKey, VL, 49) // account (common) TYPED_SFIELD(sfAccount, ACCOUNT, 1) @@ -422,6 +431,8 @@ UNTYPED_SFIELD(sfBatchSigner, OBJECT, 35) UNTYPED_SFIELD(sfBook, OBJECT, 36) UNTYPED_SFIELD(sfCounterpartySignature, OBJECT, 37, SField::kSmdDefault, SField::kNotSigning) UNTYPED_SFIELD(sfSponsorSignature, OBJECT, 38, SField::kSmdDefault, SField::kNotSigning) +UNTYPED_SFIELD(sfBallotOption, OBJECT, 39) +UNTYPED_SFIELD(sfBallotResult, OBJECT, 40) // array of objects (common) // ARRAY/1 is reserved for end of array @@ -456,3 +467,8 @@ UNTYPED_SFIELD(sfAcceptedCredentials, ARRAY, 28) UNTYPED_SFIELD(sfPermissions, ARRAY, 29) UNTYPED_SFIELD(sfRawTransactions, ARRAY, 30) UNTYPED_SFIELD(sfBatchSigners, ARRAY, 31, SField::kSmdDefault, SField::kNotSigning) +UNTYPED_SFIELD(sfEncryptedTally, ARRAY, 32) +UNTYPED_SFIELD(sfEncryptedVotes, ARRAY, 33) +UNTYPED_SFIELD(sfAuditorEncryptedVotes, ARRAY, 34) +UNTYPED_SFIELD(sfVoterEncryptedVotes, ARRAY, 35) +UNTYPED_SFIELD(sfResults, ARRAY, 36) diff --git a/include/xrpl/protocol/detail/transactions.macro b/include/xrpl/protocol/detail/transactions.macro index e805596c008..37b5ad220e6 100644 --- a/include/xrpl/protocol/detail/transactions.macro +++ b/include/xrpl/protocol/detail/transactions.macro @@ -1194,6 +1194,72 @@ TRANSACTION(ttSPONSORSHIP_SET, 91, SponsorshipSet, {sfRemainingOwnerCount, SoeOptional}, })) +/** This transaction type creates a confidential Ballot. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttBALLOT_CREATE, 103, BallotCreate, + Delegation::NotDelegable, + featureConfidentialVoting, + NoPriv, + ({ + {sfMPTokenIssuanceID, SoeOptional}, + {sfDomainID, SoeOptional}, + {sfDigest, SoeRequired}, + {sfURI, SoeOptional}, + {sfOptionCount, SoeRequired}, + {sfTallyPublicKey, SoeRequired}, + {sfAuditorEncryptionKey, SoeOptional}, + {sfOpenTime, SoeRequired}, + {sfCloseTime, SoeRequired}, + {sfZKProof, SoeRequired}, +})) + +/** This transaction type casts an encrypted vote on a Ballot. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttBALLOT_CAST_VOTE, 104, BallotCastVote, + Delegation::NotDelegable, + featureConfidentialVoting, + NoPriv, + ({ + {sfBallotID, SoeRequired}, + {sfEncryptedVotes, SoeRequired}, + {sfAuditorEncryptedVotes, SoeOptional}, + {sfVoterPublicKey, SoeOptional}, + {sfVoterEncryptedVotes, SoeOptional}, + {sfZKProof, SoeRequired}, + {sfBlindingFactor, SoeRequired}, + {sfCredentialIDs, SoeOptional}, +})) + +/** This transaction type publishes the verifiable results of a Ballot. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttBALLOT_FINALIZE, 105, BallotFinalize, + Delegation::NotDelegable, + featureConfidentialVoting, + NoPriv, + ({ + {sfBallotID, SoeRequired}, + {sfResults, SoeRequired}, + {sfZKProof, SoeRequired}, +})) + +/** This transaction type deletes a Ballot or a BallotVote object. */ +#if TRANSACTION_INCLUDE +# include +#endif +TRANSACTION(ttBALLOT_DELETE, 106, BallotDelete, + Delegation::NotDelegable, + featureConfidentialVoting, + NoPriv, + ({ + {sfBallotID, SoeRequired}, +})) + /** This system-generated transaction type is used to update the status of the various amendments. For details, see: https://xrpl.org/amendments.html diff --git a/include/xrpl/protocol/jss.h b/include/xrpl/protocol/jss.h index 63e877ca311..8fa33310201 100644 --- a/include/xrpl/protocol/jss.h +++ b/include/xrpl/protocol/jss.h @@ -145,6 +145,7 @@ JSS(avg_bps_recv); // out: Peers JSS(avg_bps_sent); // out: Peers JSS(balance); // out: AccountLines JSS(balances); // out: GatewayBalances +JSS(ballot_id); // in: LedgerEntry JSS(base); // out: LogLevel JSS(base_asset); // in: get_aggregate_price JSS(base_fee); // out: NetworkOPs diff --git a/include/xrpl/tx/invariants/BallotInvariant.h b/include/xrpl/tx/invariants/BallotInvariant.h new file mode 100644 index 00000000000..016ad1dc3af --- /dev/null +++ b/include/xrpl/tx/invariants/BallotInvariant.h @@ -0,0 +1,72 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +/** + * @brief Invariants: Confidential ballot consistency. + * + * - The encrypted tally array always has exactly OptionCount entries. + * - A results array, when present, has exactly OptionCount entries and only + * appears on a finalized ballot. + * - A ballot's VoteCount never decreases, and rises by exactly the number of + * BallotVote objects created against it in the same transaction. + * - No BallotVote is created without a matching VoteCount increment on its + * ballot. + */ +class ValidBallot +{ + struct BallotChange + { + std::shared_ptr before; + std::shared_ptr after; + }; + + std::map ballots_; + std::map createdVotes_; + +public: + /** + * @brief Track ballot modifications and BallotVote creations. + * + * @param isDelete Whether the ledger entry is being deleted. + * @param before The ledger entry before transaction application. + * @param after The ledger entry after transaction application. + */ + void + visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after); + + /** + * @brief Verify ballot tally, results, and vote-count invariants. + * + * @param tx The transaction being checked. + * @param result The transaction result code. + * @param fee The fee charged by the transaction. + * @param view The ledger view after transaction application. + * @param j Journal used for diagnostics. + * @return true if the invariant checks pass, otherwise false. + */ + bool + finalize( + STTx const& tx, + TER const result, + XRPAmount const fee, + ReadView const& view, + beast::Journal const& j); +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/invariants/InvariantCheck.h b/include/xrpl/tx/invariants/InvariantCheck.h index 1239305e792..5df84a1c4c8 100644 --- a/include/xrpl/tx/invariants/InvariantCheck.h +++ b/include/xrpl/tx/invariants/InvariantCheck.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -463,7 +464,8 @@ using InvariantChecks = std::tuple< ValidMPTTransfer, ObjectHasPseudoAccount, SponsorshipOwnerCountsMatch, - SponsorshipAccountCountMatchesField>; + SponsorshipAccountCountMatchesField, + ValidBallot>; /** * @brief get a tuple of all invariant checks diff --git a/include/xrpl/tx/transactors/voting/BallotCastVote.h b/include/xrpl/tx/transactors/voting/BallotCastVote.h new file mode 100644 index 00000000000..50dbd39d679 --- /dev/null +++ b/include/xrpl/tx/transactors/voting/BallotCastVote.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Casts an encrypted, zero-knowledge-proven vote onto a Ballot. + * + * Every cast submits a vector of N ciphertexts (one per option) under the + * tally key: the chosen option(s) encrypt the voter's weight, the rest encrypt + * zero. Validators homomorphically add the vector into the ballot's encrypted + * tally, so the running count stays hidden. The cast proves each option value + * is non-negative (aggregated Bulletproof) and that the vector sums to the + * voter's public weight W. + * + * @note v1 crypto: range and sum-to-W are verified against the merged + * mpt-crypto 0.4.0-rc4 primitives. The per-option ciphertext-commitment + * linkage requires a verifiable-encryption primitive not yet exported by + * the library; credential-mode casts are gated (rejected) on it. See + * plan-2-implementation.md. + * + * @see BallotCreate, BallotFinalize + */ +class BallotCastVote : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit BallotCastVote(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/voting/BallotCreate.h b/include/xrpl/tx/transactors/voting/BallotCreate.h new file mode 100644 index 00000000000..f1577b7f282 --- /dev/null +++ b/include/xrpl/tx/transactors/voting/BallotCreate.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +/** + * @brief Creates a confidential Ballot with a homomorphically encrypted tally. + * + * A ballot references exactly one eligibility source: an MPT issuance + * (token-weighted voting) or a permissioned domain (credential-gated, one + * account one vote). Each per-option tally counter is initialized to a + * canonical encryption of zero under the tally authority's ElGamal key, which + * the creator registers with a Schnorr proof of key possession. + * + * @see BallotCastVote, BallotFinalize, BallotDelete + */ +class BallotCreate : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit BallotCreate(ApplyContext& ctx) : Transactor(ctx) + { + } + + static bool + checkExtraFeatures(PreflightContext const& ctx); + + static std::uint32_t + getFlagsMask(PreflightContext const& ctx); + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/voting/BallotDelete.h b/include/xrpl/tx/transactors/voting/BallotDelete.h new file mode 100644 index 00000000000..d8d55b1eccf --- /dev/null +++ b/include/xrpl/tx/transactors/voting/BallotDelete.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +/** + * @brief Deletes a Ballot or a BallotVote to reclaim its owner reserve. + * + * The ballot creator may delete a finalized ballot, or an unfinalized one past + * its close time. A voter may delete their own BallotVote any time after the + * ballot closes. BallotVote objects are independent of the Ballot's lifetime, + * so deleting the ballot never orphans a voter's reserve. + * + * @see BallotCreate, BallotCastVote, BallotFinalize + */ +class BallotDelete : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit BallotDelete(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/include/xrpl/tx/transactors/voting/BallotFinalize.h b/include/xrpl/tx/transactors/voting/BallotFinalize.h new file mode 100644 index 00000000000..bd1b26cc104 --- /dev/null +++ b/include/xrpl/tx/transactors/voting/BallotFinalize.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace xrpl { + +/** + * @brief Publishes the verifiable plaintext results of a closed Ballot. + * + * After the voting window closes, the tally authority submits the claimed + * per-option counts together with a per-option decryption-correctness proof + * (a discrete-log-equality proof against the on-ledger tally ciphertexts under + * the tally key). Validators reject any counts that do not match the ciphertexts, + * so the result is trustless even though the tallying is not. + * + * @see BallotCreate, BallotCastVote + */ +class BallotFinalize : public Transactor +{ +public: + static constexpr auto kConsequencesFactory = ConsequencesFactoryType::Normal; + + explicit BallotFinalize(ApplyContext& ctx) : Transactor(ctx) + { + } + + static NotTEC + preflight(PreflightContext const& ctx); + + static XRPAmount + calculateBaseFee(ReadView const& view, STTx const& tx); + + static TER + preclaim(PreclaimContext const& ctx); + + TER + doApply() override; + + void + visitInvariantEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) override; + + [[nodiscard]] bool + finalizeInvariants( + STTx const& tx, + TER result, + XRPAmount fee, + ReadView const& view, + beast::Journal const& j) override; +}; + +} // namespace xrpl diff --git a/src/libxrpl/ledger/helpers/TokenHelpers.cpp b/src/libxrpl/ledger/helpers/TokenHelpers.cpp index 79e10cdf79f..58c6bf8ddac 100644 --- a/src/libxrpl/ledger/helpers/TokenHelpers.cpp +++ b/src/libxrpl/ledger/helpers/TokenHelpers.cpp @@ -396,6 +396,29 @@ accountHolds( view, account, issue.currency, issue.account, zeroIfFrozen, j, includeFullBalance); } +// ConfidentialVoting: amount locked on an MPToken while a ballot it voted on is +// still open. The lock keys off the ballot's CloseTime, so it releases +// automatically once the voting window ends, with no cleanup transaction. +static std::uint64_t +activeVoteLock(ReadView const& view, std::shared_ptr const& sleMpt) +{ + if (!view.rules().enabled(featureConfidentialVoting) || + !sleMpt->isFieldPresent(sfVoteLockedAmount) || !sleMpt->isFieldPresent(sfBallotID)) + { + return 0; + } + + auto const sleBallot = view.read(keylet::ballot((*sleMpt)[sfBallotID])); + if (!sleBallot) + return 0; // ballot deleted; lock no longer applies + + auto const closeTime = (*sleBallot)[sfCloseTime]; + if (view.parentCloseTime().time_since_epoch().count() >= closeTime) + return 0; // window closed; lock released + + return (*sleMpt)[sfVoteLockedAmount]; +} + STAmount accountHolds( ReadView const& view, @@ -458,6 +481,20 @@ accountHolds( !sleMpt->isFlag(lsfMPTAuthorized)) amount.clear(mptIssue); } + + // Reduce spendable balance by any active vote lock (token-mode ballots). + // FullBalance requests (e.g. accounting/display) see the raw balance. + if (includeFullBalance != SpendableHandling::FullBalance) + { + if (auto const locked = activeVoteLock(view, sleMpt); locked > 0) + { + auto const cur = amount.mpt().value(); // std::int64_t + auto const lockedSigned = static_cast(locked); + amount = STAmount{ + mptIssue, + static_cast(cur > lockedSigned ? cur - lockedSigned : 0)}; + } + } } if (view.rules().enabled(featureMPTokensV2)) diff --git a/src/libxrpl/protocol/ConfidentialTransfer.cpp b/src/libxrpl/protocol/ConfidentialTransfer.cpp index fe8a08c2eff..fae494511f9 100644 --- a/src/libxrpl/protocol/ConfidentialTransfer.cpp +++ b/src/libxrpl/protocol/ConfidentialTransfer.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -19,6 +20,7 @@ #include #include +#include #include #include #include @@ -485,4 +487,118 @@ verifyConvertBackProof( return tesSUCCESS; } +uint256 +getBallotCastContextHash(AccountID const& account, uint256 const& ballotID, std::uint32_t sequence) +{ + // Domain-separated from other confidential context hashes by a distinct tag. + return sha512Half(std::uint16_t(0x4243) /* 'BC' */, account, ballotID, sequence); +} + +uint256 +getBallotFinalizeContextHash( + AccountID const& account, + uint256 const& ballotID, + std::uint32_t sequence) +{ + return sha512Half(std::uint16_t(0x4246) /* 'BF' */, account, ballotID, sequence); +} + +TER +verifyBallotRangeProof( + Slice const& proof, + std::vector const& commitments, + uint256 const& contextHash) +{ + std::vector ptrs; + ptrs.reserve(commitments.size()); + for (auto const& c : commitments) + { + if (c.size() != kEcPedersenCommitmentLength) + return tecINTERNAL; // LCOV_EXCL_LINE + ptrs.push_back(c.data()); + } + + if (mpt_verify_aggregated_bulletproof( + proof.data(), proof.size(), ptrs.data(), ptrs.size(), contextHash.data()) != 0) + { + return tecBAD_PROOF; + } + + return tesSUCCESS; +} + +TER +verifyBallotVoteLinkage( + std::vector const& pubKeys, + Slice const& c1, + std::vector const& c2PerKey, + Slice const& commitment, + Slice const& proof, + uint256 const& contextHash) +{ + auto const n = pubKeys.size(); + if (n == 0 || n > 3 || c2PerKey.size() != n) + return tecINTERNAL; // LCOV_EXCL_LINE + if (proof.size() != kEcSendSigmaProofLength) + return tecBAD_PROOF; + if (c1.size() != kEcCiphertextComponentLength || + commitment.size() != kEcPedersenCommitmentLength) + return tecBAD_PROOF; + + auto* const ctx = secp256k1Context(); + + secp256k1_pubkey c1Pt; + if (secp256k1_ec_pubkey_parse(ctx, &c1Pt, c1.data(), c1.size()) != 1) + return tecBAD_PROOF; + + std::vector c2Pts(n); + std::vector pkPts(n); + for (std::size_t i = 0; i < n; ++i) + { + if (c2PerKey[i].size() != kEcCiphertextComponentLength || + pubKeys[i].size() != kEcPubKeyLength) + return tecBAD_PROOF; + if (secp256k1_ec_pubkey_parse(ctx, &c2Pts[i], c2PerKey[i].data(), c2PerKey[i].size()) != 1) + return tecBAD_PROOF; + if (secp256k1_ec_pubkey_parse(ctx, &pkPts[i], pubKeys[i].data(), pubKeys[i].size()) != 1) + return tecBAD_PROOF; + } + + secp256k1_pubkey pcm; + if (secp256k1_ec_pubkey_parse(ctx, &pcm, commitment.data(), commitment.size()) != 1) + return tecBAD_PROOF; + + // Canonical vacuous balance witness: sk_A = 1 makes pk_A = B1 = B2 = G, and + // rho_b = 1 with b = 0 makes PC_b = H. Both sides derive these identically, + // so nothing balance-related is carried on the wire and the terms constrain + // nothing about the vote. + std::array one{}; + one[kEcScalarLength - 1] = 1; + secp256k1_pubkey pkA; + if (secp256k1_ec_pubkey_create(ctx, &pkA, one.data()) != 1) + return tecINTERNAL; // LCOV_EXCL_LINE + secp256k1_pubkey pcb; + if (secp256k1_mpt_get_h_generator(ctx, &pcb) != 1) + return tecINTERNAL; // LCOV_EXCL_LINE + + if (secp256k1_compact_standard_verify( + ctx, + proof.data(), + n, + &c1Pt, + c2Pts.data(), + pkPts.data(), + &pcm, + &pkA, + &pcb, + &pkA, // B1 = G + &pkA, // B2 = G + contextHash.data()) != 1) + { + return tecBAD_PROOF; + } + + return tesSUCCESS; +} + } // namespace xrpl diff --git a/src/libxrpl/protocol/Indexes.cpp b/src/libxrpl/protocol/Indexes.cpp index 95416d0f2ac..98fada84440 100644 --- a/src/libxrpl/protocol/Indexes.cpp +++ b/src/libxrpl/protocol/Indexes.cpp @@ -104,6 +104,8 @@ enum class LedgerNameSpace : std::uint16_t { LoanBroker = 'l', // lower-case L Loan = 'L', Sponsorship = '>', + Ballot = 'b', + BallotVote = 'v', // No longer used or supported. Left here to reserve the space to avoid accidental reuse. Contract [[deprecated]] = 'c', @@ -611,6 +613,18 @@ permissionedDomain(uint256 const& domainID) noexcept return {ltPERMISSIONED_DOMAIN, domainID}; } +Keylet +ballot(AccountID const& owner, std::uint32_t seq) noexcept +{ + return {ltBALLOT, indexHash(LedgerNameSpace::Ballot, owner, seq)}; +} + +Keylet +ballotVote(uint256 const& ballotID, AccountID const& voter) noexcept +{ + return {ltBALLOT_VOTE, indexHash(LedgerNameSpace::BallotVote, ballotID, voter)}; +} + } // namespace keylet } // namespace xrpl diff --git a/src/libxrpl/protocol/InnerObjectFormats.cpp b/src/libxrpl/protocol/InnerObjectFormats.cpp index 0bdb217771c..53aa05e3eeb 100644 --- a/src/libxrpl/protocol/InnerObjectFormats.cpp +++ b/src/libxrpl/protocol/InnerObjectFormats.cpp @@ -168,6 +168,24 @@ InnerObjectFormats::InnerObjectFormats() {sfTxnSignature, SoeOptional}, {sfSigners, SoeOptional}, }); + + // One entry in a confidential ballot ciphertext vector: an ElGamal + // ciphertext, plus (on cast transactions) the matching Pedersen + // commitment used for the aggregated range proof. + add(sfBallotOption.jsonName, + sfBallotOption.getCode(), + { + {sfEncryptedVote, SoeRequired}, + {sfAmountCommitment, SoeOptional}, + {sfZKProof, SoeOptional}, + }); + + // One entry in a finalized ballot's plaintext result vector. + add(sfBallotResult.jsonName, + sfBallotResult.getCode(), + { + {sfBallotWeight, SoeRequired}, + }); } InnerObjectFormats const& diff --git a/src/libxrpl/protocol/TER.cpp b/src/libxrpl/protocol/TER.cpp index c2167d58ce3..e6c393635a0 100644 --- a/src/libxrpl/protocol/TER.cpp +++ b/src/libxrpl/protocol/TER.cpp @@ -108,6 +108,10 @@ transResults() MAKE_ERROR(tecPRECISION_LOSS, "The amounts used by the transaction cannot interact."), MAKE_ERROR(tecBAD_PROOF, "Proof cannot be verified"), MAKE_ERROR(tecNO_SPONSOR_PERMISSION, "Sponsor has not authorized this transaction."), + MAKE_ERROR(tecBALLOT_CLOSED, "The ballot voting window has closed."), + MAKE_ERROR(tecBALLOT_VOTED, "The account has already voted on this ballot."), + MAKE_ERROR(tecBALLOT_NOT_OPEN, "The ballot voting window has not opened yet."), + MAKE_ERROR(tecBALLOT_EXISTS, "The issuance already has an open ballot."), MAKE_ERROR(tefALREADY, "The exact transaction was already in this ledger."), MAKE_ERROR(tefBAD_ADD_AUTH, "Not authorized to add account."), diff --git a/src/libxrpl/tx/invariants/BallotInvariant.cpp b/src/libxrpl/tx/invariants/BallotInvariant.cpp new file mode 100644 index 00000000000..a1cd642c024 --- /dev/null +++ b/src/libxrpl/tx/invariants/BallotInvariant.cpp @@ -0,0 +1,94 @@ +#include + +#include +#include +#include +#include + +#include + +namespace xrpl { + +void +ValidBallot::visitEntry( + bool isDelete, + std::shared_ptr const& before, + std::shared_ptr const& after) +{ + if (!isDelete && after && after->getType() == ltBALLOT) + { + auto& change = ballots_[after->key()]; + change.before = before; + change.after = after; + } + + if (!isDelete && !before && after && after->getType() == ltBALLOT_VOTE) + createdVotes_[(*after)[sfBallotID]] += 1; +} + +bool +ValidBallot::finalize( + STTx const&, + TER const, + XRPAmount const, + ReadView const&, + beast::Journal const& j) +{ + for (auto const& [key, change] : ballots_) + { + auto const& b = change.after; + std::uint32_t const optionCount = (*b)[sfOptionCount]; + + if (!b->isFieldPresent(sfEncryptedTally) || + b->getFieldArray(sfEncryptedTally).size() != optionCount) + { + JLOG(j.fatal()) << "Invariant failed: ballot tally length != OptionCount"; + return false; + } + + if (b->isFieldPresent(sfResults)) + { + if (!b->isFlag(lsfBallotFinalized)) + { + JLOG(j.fatal()) << "Invariant failed: ballot results present without finalize flag"; + return false; + } + if (b->getFieldArray(sfResults).size() != optionCount) + { + JLOG(j.fatal()) << "Invariant failed: ballot results length != OptionCount"; + return false; + } + } + + std::uint32_t const afterVotes = (*b)[sfVoteCount]; + std::uint32_t const beforeVotes = change.before ? (*change.before)[sfVoteCount] : 0u; + if (afterVotes < beforeVotes) + { + JLOG(j.fatal()) << "Invariant failed: ballot VoteCount decreased"; + return false; + } + + auto const it = createdVotes_.find(key); + std::uint32_t const created = it == createdVotes_.end() ? 0u : it->second; + if (afterVotes - beforeVotes != created) + { + JLOG(j.fatal()) << "Invariant failed: ballot VoteCount delta != BallotVotes created"; + return false; + } + } + + // Every created BallotVote must correspond to a VoteCount increment on a + // ballot modified in the same transaction. + for (auto const& [ballotID, count] : createdVotes_) + { + if (!ballots_.contains(ballotID)) + { + JLOG(j.fatal()) << "Invariant failed: BallotVote created without ballot update"; + return false; + } + } + + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/voting/BallotCastVote.cpp b/src/libxrpl/tx/transactors/voting/BallotCastVote.cpp new file mode 100644 index 00000000000..8a31d5a30e6 --- /dev/null +++ b/src/libxrpl/tx/transactors/voting/BallotCastVote.cpp @@ -0,0 +1,483 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace xrpl { + +namespace { + +// Every ciphertext entry in a cast is a 66-byte ElGamal ciphertext; entries in +// the primary vote vector additionally carry a 33-byte Pedersen commitment. +bool +validCiphertextArray(STArray const& arr, bool requireCommitment) +{ + for (auto const& entry : arr) + { + if (!entry.isFieldPresent(sfEncryptedVote) || + entry[sfEncryptedVote].length() != kEcGamalEncryptedTotalLength || + !isValidCiphertext(entry[sfEncryptedVote])) + { + return false; + } + + bool const hasCommitment = entry.isFieldPresent(sfAmountCommitment); + if (requireCommitment != hasCommitment) + return false; + + if (hasCommitment && !isValidCompressedECPoint(entry[sfAmountCommitment])) + return false; + + // The primary vector also carries a per-option linkage proof binding the + // ciphertext to its range-proven commitment. + if (requireCommitment) + { + if (!entry.isFieldPresent(sfZKProof) || + entry[sfZKProof].length() != kEcSendSigmaProofLength) + return false; + } + } + return true; +} + +// Homomorphic sum of the ciphertexts in a vote vector. +std::optional +sumCiphertexts(STArray const& arr) +{ + std::optional acc; + for (auto const& entry : arr) + { + Slice const ct = entry[sfEncryptedVote]; + if (!acc) + { + acc.emplace(ct.data(), ct.size()); + continue; + } + acc = homomorphicAdd(*acc, ct); + if (!acc) + return std::nullopt; + } + return acc; +} + +// Verify the homomorphic sum of a vote vector equals Enc(weight; R) under key. +// Revealing the aggregate randomness R leaks nothing (weight is public) but +// pins the vector's total to the voter's weight, blocking weight inflation. +TER +verifySumToWeight( + STArray const& votes, + Slice const& pubKey, + std::uint64_t weight, + Slice const& blinding) +{ + auto const sum = sumCiphertexts(votes); + if (!sum) + return tecBAD_PROOF; + + auto const expected = encryptAmount(weight, pubKey, blinding); + if (!expected) + return tecBAD_PROOF; + + if (sum->size() != expected->size() || + std::memcmp(sum->data(), expected->data(), sum->size()) != 0) + { + return tecBAD_PROOF; + } + return tesSUCCESS; +} + +// Copy a cast vote vector into a lean stored form (ciphertext only). +STArray +storedVoteVector(STArray const& src) +{ + STArray out; + for (auto const& entry : src) + { + STObject o = STObject::makeInnerObject(sfBallotOption); + o.setFieldVL(sfEncryptedVote, entry[sfEncryptedVote]); + out.push_back(std::move(o)); + } + return out; +} + +} // namespace + +bool +BallotCastVote::checkExtraFeatures(PreflightContext const& ctx) +{ + return !ctx.tx.isFieldPresent(sfCredentialIDs) || ctx.rules.enabled(featureCredentials); +} + +NotTEC +BallotCastVote::preflight(PreflightContext const& ctx) +{ + auto const& votes = ctx.tx.getFieldArray(sfEncryptedVotes); + if (votes.empty() || votes.size() > 8) + return temMALFORMED; + + // Primary vote vector carries ciphertext + range-proof commitment per option. + if (!validCiphertextArray(votes, /*requireCommitment=*/true)) + return temBAD_CIPHERTEXT; + + bool const hasAuditor = ctx.tx.isFieldPresent(sfAuditorEncryptedVotes); + if (hasAuditor) + { + auto const& av = ctx.tx.getFieldArray(sfAuditorEncryptedVotes); + if (av.size() != votes.size() || !validCiphertextArray(av, /*requireCommitment=*/false)) + return temBAD_CIPHERTEXT; + } + + bool const hasVoterKey = ctx.tx.isFieldPresent(sfVoterPublicKey); + bool const hasVoterVotes = ctx.tx.isFieldPresent(sfVoterEncryptedVotes); + if (hasVoterKey != hasVoterVotes) + return temMALFORMED; + + if (hasVoterKey) + { + if (!isValidCompressedECPoint(ctx.tx[sfVoterPublicKey])) + return temMALFORMED; + + auto const& vv = ctx.tx.getFieldArray(sfVoterEncryptedVotes); + if (vv.size() != votes.size() || !validCiphertextArray(vv, /*requireCommitment=*/false)) + return temBAD_CIPHERTEXT; + } + + if (ctx.tx[sfZKProof].empty()) + return temMALFORMED; + + if (auto const err = credentials::checkFields(ctx.tx, ctx.j); !isTesSuccess(err)) + return err; + + return tesSUCCESS; +} + +XRPAmount +BallotCastVote::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +BallotCastVote::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; + + auto const ballotID = ctx.tx[sfBallotID]; + auto const sleBallot = ctx.view.read(keylet::ballot(ballotID)); + if (!sleBallot) + return tecNO_ENTRY; + + if (sleBallot->isFlag(lsfBallotFinalized)) + return tecBALLOT_CLOSED; + + // Voting window: [OpenTime, CloseTime). + auto const now = ctx.view.parentCloseTime().time_since_epoch().count(); + if (now < (*sleBallot)[sfOpenTime]) + return tecBALLOT_NOT_OPEN; + if (now >= (*sleBallot)[sfCloseTime]) + return tecBALLOT_CLOSED; + + // Vote vectors must have exactly one entry per option. + std::uint8_t const n = (*sleBallot)[sfOptionCount]; + auto const& votes = ctx.tx.getFieldArray(sfEncryptedVotes); + if (votes.size() != n) + return temMALFORMED; + + // Auditor / voter-recovery presence must match the ballot's configuration. + bool const ballotHasAuditor = sleBallot->isFieldPresent(sfAuditorEncryptionKey); + if (ballotHasAuditor != ctx.tx.isFieldPresent(sfAuditorEncryptedVotes)) + return tecNO_PERMISSION; + + bool const ballotRecoverable = sleBallot->isFlag(lsfVoterRecoverable); + if (ballotRecoverable != ctx.tx.isFieldPresent(sfVoterPublicKey)) + return tecNO_PERMISSION; + + // One BallotVote per account per ballot. + if (ctx.view.exists(keylet::ballotVote(ballotID, account))) + return tecBALLOT_VOTED; + + bool const tokenMode = sleBallot->isFieldPresent(sfMPTokenIssuanceID); + + // Eligibility and weight. + std::uint64_t weight = 0; + if (tokenMode) + { + MPTIssue const mptIssue{(*sleBallot)[sfMPTokenIssuanceID]}; + if (!ctx.view.exists(keylet::mptoken(mptIssue.getMptID(), account))) + return tecNO_ENTRY; + + auto const held = accountHolds( + ctx.view, + account, + mptIssue, + FreezeHandling::ZeroIfFrozen, + AuthHandling::ZeroIfUnauthorized, + ctx.j); + if (held.mpt().value() <= 0) + return tecNO_ENTRY; + weight = static_cast(held.mpt().value()); + } + else + { + if (!permissioned_dex::accountInDomain(ctx.view, account, (*sleBallot)[sfDomainID])) + return tecNO_PERMISSION; + weight = 1; + } + + // --- Cast proof verification ------------------------------------------- + auto const ctxHash = getBallotCastContextHash(account, ballotID, ctx.tx.getSeqProxy().value()); + auto const blindingFactor = ctx.tx[sfBlindingFactor]; + Slice const blinding{blindingFactor.data(), blindingFactor.size()}; + + // 1. Range: each committed option value is non-negative. + std::vector commitments; + commitments.reserve(votes.size()); + for (auto const& entry : votes) + commitments.push_back(entry[sfAmountCommitment]); + if (auto const ter = verifyBallotRangeProof(ctx.tx[sfZKProof], commitments, ctxHash); + !isTesSuccess(ter)) + return ter; + + // 2. Sum-to-weight against the tally key. + if (auto const ter = verifySumToWeight(votes, (*sleBallot)[sfTallyPublicKey], weight, blinding); + !isTesSuccess(ter)) + return ter; + + // 3. Mirror consistency: auditor / voter vectors carry the same aggregate. + if (ballotHasAuditor) + { + if (auto const ter = verifySumToWeight( + ctx.tx.getFieldArray(sfAuditorEncryptedVotes), + (*sleBallot)[sfAuditorEncryptionKey], + weight, + blinding); + !isTesSuccess(ter)) + return ter; + } + if (ballotRecoverable) + { + if (auto const ter = verifySumToWeight( + ctx.tx.getFieldArray(sfVoterEncryptedVotes), + ctx.tx[sfVoterPublicKey], + weight, + blinding); + !isTesSuccess(ter)) + return ter; + } + + // 4. Per-option ciphertext-commitment linkage. Without it a voter could put + // (W+k, -k) in the ciphertexts while committing (W, 0): sum and range + // both pass, but the tally would be corrupted. Each option's proof shows + // its ciphertext (and any auditor/voter mirror, under shared randomness) + // encrypts exactly the range-proven committed value. Sound for both token + // and credential mode. + Slice const tallyKey = (*sleBallot)[sfTallyPublicKey]; + STArray const* auditorVotes = + ballotHasAuditor ? &ctx.tx.getFieldArray(sfAuditorEncryptedVotes) : nullptr; + STArray const* voterVotes = + ballotRecoverable ? &ctx.tx.getFieldArray(sfVoterEncryptedVotes) : nullptr; + + for (std::uint8_t i = 0; i < n; ++i) + { + Slice const tallyCt = votes[i][sfEncryptedVote]; + Slice const c1{tallyCt.data(), kEcCiphertextComponentLength}; + + std::vector mirrorKeys{tallyKey}; + std::vector c2PerKey{ + Slice{tallyCt.data() + kEcCiphertextComponentLength, kEcCiphertextComponentLength}}; + + // Auditor / voter mirrors must share the option's ElGamal randomness, so + // their C1 must equal the tally C1; only then does one shared-C1 linkage + // proof pin every mirror to the same value. + auto const addMirror = [&](STArray const* arr, Slice const& key) -> bool { + Slice const ct = (*arr)[i][sfEncryptedVote]; + if (std::memcmp(ct.data(), c1.data(), kEcCiphertextComponentLength) != 0) + return false; + mirrorKeys.push_back(key); + c2PerKey.push_back( + Slice{ct.data() + kEcCiphertextComponentLength, kEcCiphertextComponentLength}); + return true; + }; + if (auditorVotes && !addMirror(auditorVotes, (*sleBallot)[sfAuditorEncryptionKey])) + return tecBAD_PROOF; + if (voterVotes && !addMirror(voterVotes, ctx.tx[sfVoterPublicKey])) + return tecBAD_PROOF; + + if (auto const ter = verifyBallotVoteLinkage( + mirrorKeys, + c1, + c2PerKey, + votes[i][sfAmountCommitment], + votes[i][sfZKProof], + ctxHash); + !isTesSuccess(ter)) + { + return ter; + } + } + + return tesSUCCESS; +} + +TER +BallotCastVote::doApply() +{ + auto const ballotID = ctx_.tx[sfBallotID]; + auto sleBallot = view().peek(keylet::ballot(ballotID)); + if (!sleBallot) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const sleOwner = view().peek(keylet::account(accountID_)); + if (!sleOwner) + return tecINTERNAL; // LCOV_EXCL_LINE + + Slice const tallyKey = (*sleBallot)[sfTallyPublicKey]; + auto const blindingFactor = ctx_.tx[sfBlindingFactor]; + Slice const blinding{blindingFactor.data(), blindingFactor.size()}; + auto const& votes = ctx_.tx.getFieldArray(sfEncryptedVotes); + STArray const& oldTally = sleBallot->getFieldArray(sfEncryptedTally); + + if (votes.size() != oldTally.size()) + return tecINTERNAL; // LCOV_EXCL_LINE + + // Homomorphically add each (re-randomized) vote ciphertext into its tally + // counter. Re-randomizing with a public deterministic scalar keeps the + // ledger reproducible while adding Enc(0), so the plaintext tally is + // unchanged. + STArray newTally; + for (std::size_t i = 0; i < oldTally.size(); ++i) + { + auto rr = rerandomizeCiphertext(votes[i][sfEncryptedVote], tallyKey, blinding); + if (!rr) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto sum = homomorphicAdd(oldTally[i][sfEncryptedVote], *rr); + if (!sum) + return tecINTERNAL; // LCOV_EXCL_LINE + + STObject entry = STObject::makeInnerObject(sfBallotOption); + entry.setFieldVL(sfEncryptedVote, *sum); + newTally.push_back(std::move(entry)); + } + sleBallot->setFieldArray(sfEncryptedTally, newTally); + (*sleBallot)[sfVoteCount] = (*sleBallot)[sfVoteCount] + 1u; + + // Recompute the public weight (mirrors preclaim; the lock is not yet set). + bool const tokenMode = sleBallot->isFieldPresent(sfMPTokenIssuanceID); + std::uint64_t weight = 1; + std::optional mptIssue; + if (tokenMode) + { + mptIssue.emplace((*sleBallot)[sfMPTokenIssuanceID]); + auto const held = accountHolds( + view(), + accountID_, + *mptIssue, + FreezeHandling::ZeroIfFrozen, + AuthHandling::ZeroIfUnauthorized, + j_); + weight = static_cast(held.mpt().value()); + } + + // Create the BallotVote object. + auto const voteKeylet = keylet::ballotVote(ballotID, accountID_); + auto sleVote = std::make_shared(voteKeylet); + (*sleVote)[sfAccount] = accountID_; + (*sleVote)[sfBallotID] = ballotID; + (*sleVote)[sfBallotWeight] = weight; + sleVote->setFieldArray(sfEncryptedVotes, storedVoteVector(votes)); + if (ctx_.tx.isFieldPresent(sfAuditorEncryptedVotes)) + { + sleVote->setFieldArray( + sfAuditorEncryptedVotes, + storedVoteVector(ctx_.tx.getFieldArray(sfAuditorEncryptedVotes))); + } + if (ctx_.tx.isFieldPresent(sfVoterPublicKey)) + { + (*sleVote)[sfVoterPublicKey] = ctx_.tx[sfVoterPublicKey]; + sleVote->setFieldArray( + sfVoterEncryptedVotes, storedVoteVector(ctx_.tx.getFieldArray(sfVoterEncryptedVotes))); + } + + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), sleOwner, preFeeBalance_, {.ownerCountDelta = 1}, j_); + !isTesSuccess(ret)) + return ret; + + auto const page = + view().dirInsert(keylet::ownerDir(accountID_), voteKeylet, describeOwnerDir(accountID_)); + if (!page) + return tecDIR_FULL; // LCOV_EXCL_LINE + (*sleVote)[sfOwnerNode] = *page; + + increaseOwnerCount(ctx_.getApplyViewContext(), sleOwner, 1, j_); + addSponsorToLedgerEntry(ctx_.getApplyViewContext(), sleVote); + view().insert(sleVote); + + // Token mode: lock the voter's balance until the ballot closes. + if (tokenMode) + { + auto sleMpt = view().peek(keylet::mptoken(mptIssue->getMptID(), accountID_)); + if (!sleMpt) + return tecINTERNAL; // LCOV_EXCL_LINE + (*sleMpt)[sfVoteLockedAmount] = weight; + (*sleMpt)[sfBallotID] = ballotID; + view().update(sleMpt); + } + + view().update(sleBallot); + return tesSUCCESS; +} + +void +BallotCastVote::visitInvariantEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&) +{ +} + +bool +BallotCastVote::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/voting/BallotCreate.cpp b/src/libxrpl/tx/transactors/voting/BallotCreate.cpp new file mode 100644 index 00000000000..149df740a51 --- /dev/null +++ b/src/libxrpl/tx/transactors/voting/BallotCreate.cpp @@ -0,0 +1,253 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +// Ballots allow between 2 and 8 options, inclusive. +static constexpr std::uint8_t kMinBallotOptions = 2; +static constexpr std::uint8_t kMaxBallotOptions = 8; +static constexpr std::size_t kMaxBallotUriLength = 256; + +bool +BallotCreate::checkExtraFeatures(PreflightContext const& ctx) +{ + // Credential mode needs the permissioned-domain and credential stacks; + // the base amendment is gated by the TRANSACTION() macro. + if (ctx.tx.isFieldPresent(sfDomainID)) + return ctx.rules.enabled(featurePermissionedDomains) && + ctx.rules.enabled(featureCredentials); + return true; +} + +std::uint32_t +BallotCreate::getFlagsMask(PreflightContext const&) +{ + return tfBallotCreateMask; +} + +NotTEC +BallotCreate::preflight(PreflightContext const& ctx) +{ + // Exactly one eligibility source must be present. + bool const hasIssuance = ctx.tx.isFieldPresent(sfMPTokenIssuanceID); + bool const hasDomain = ctx.tx.isFieldPresent(sfDomainID); + if (hasIssuance == hasDomain) + return temMALFORMED; + + if (hasDomain && ctx.tx[sfDomainID] == beast::kZero) + return temMALFORMED; + + auto const optionCount = ctx.tx[sfOptionCount]; + if (optionCount < kMinBallotOptions || optionCount > kMaxBallotOptions) + return temINVALID_COUNT; + + // Voting window must be non-empty. + if (ctx.tx[sfOpenTime] >= ctx.tx[sfCloseTime]) + return temMALFORMED; + + // The tally key is an ElGamal public key registered with a Schnorr proof. + if (!isValidCompressedECPoint(ctx.tx[sfTallyPublicKey])) + return temMALFORMED; + + if (ctx.tx[sfZKProof].size() != kEcSchnorrProofLength) + return temMALFORMED; + + if (ctx.tx.isFieldPresent(sfAuditorEncryptionKey) && + !isValidCompressedECPoint(ctx.tx[sfAuditorEncryptionKey])) + { + return temMALFORMED; + } + + if (auto const uri = ctx.tx[~sfURI]; uri && (uri->empty() || uri->size() > kMaxBallotUriLength)) + return temMALFORMED; + + return tesSUCCESS; +} + +XRPAmount +BallotCreate::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +BallotCreate::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; + + // The Schnorr proof of possession is bound to the ballot's own index. + auto const ballotKeylet = keylet::ballot(account, ctx.tx.getSeqValue()); + if (auto const ter = + verifySchnorrProof(ctx.tx[sfTallyPublicKey], ctx.tx[sfZKProof], ballotKeylet.key); + !isTesSuccess(ter)) + return ter; + + if (ctx.tx.isFieldPresent(sfMPTokenIssuanceID)) + { + auto const sleIssuance = + ctx.view.read(keylet::mptokenIssuance(ctx.tx[sfMPTokenIssuanceID])); + if (!sleIssuance) + return tecOBJECT_NOT_FOUND; + + // Only the issuer (in this v1) may create a token-mode ballot. + if (sleIssuance->getAccountID(sfIssuer) != account) + return tecNO_PERMISSION; + + // At most one open ballot per issuance, so a single vote-lock field on + // each MPToken is sufficient. A stale marker for a closed ballot is + // overwritten. + if (auto const openID = (*sleIssuance)[~sfBallotID]) + { + if (auto const sleOpen = ctx.view.read(keylet::ballot(*openID)); sleOpen && + ctx.view.parentCloseTime().time_since_epoch().count() < (*sleOpen)[sfCloseTime]) + { + return tecBALLOT_EXISTS; + } + } + } + else + { + auto const slePD = ctx.view.read(keylet::permissionedDomain(ctx.tx[sfDomainID])); + if (!slePD) + return tecOBJECT_NOT_FOUND; + + // Only the domain owner (in this v1) may create a credential-mode ballot. + if (slePD->getAccountID(sfOwner) != account) + return tecNO_PERMISSION; + } + + return tesSUCCESS; +} + +TER +BallotCreate::doApply() +{ + auto const sleOwner = view().peek(keylet::account(accountID_)); + if (!sleOwner) + return tefINTERNAL; // LCOV_EXCL_LINE + + if (auto const ret = checkReserve( + ctx_.getApplyViewContext(), sleOwner, preFeeBalance_, {.ownerCountDelta = 1}, j_); + !isTesSuccess(ret)) + return ret; + + auto const seq = ctx_.tx.getSeqValue(); + auto const ballotKeylet = keylet::ballot(accountID_, seq); + auto sleBallot = std::make_shared(ballotKeylet); + + (*sleBallot)[sfOwner] = accountID_; + (*sleBallot)[sfSequence] = seq; + (*sleBallot)[sfDigest] = ctx_.tx[sfDigest]; + (*sleBallot)[sfOptionCount] = ctx_.tx[sfOptionCount]; + (*sleBallot)[sfTallyPublicKey] = ctx_.tx[sfTallyPublicKey]; + (*sleBallot)[sfOpenTime] = ctx_.tx[sfOpenTime]; + (*sleBallot)[sfCloseTime] = ctx_.tx[sfCloseTime]; + (*sleBallot)[sfVoteCount] = 0u; + + std::uint32_t flags = 0; + if (ctx_.tx.isFlag(tfVoterRecoverable)) + flags |= lsfVoterRecoverable; + (*sleBallot)[sfFlags] = flags; + + if (ctx_.tx.isFieldPresent(sfMPTokenIssuanceID)) + (*sleBallot)[sfMPTokenIssuanceID] = ctx_.tx[sfMPTokenIssuanceID]; + else + (*sleBallot)[sfDomainID] = ctx_.tx[sfDomainID]; + + if (ctx_.tx.isFieldPresent(sfURI)) + (*sleBallot)[sfURI] = ctx_.tx[sfURI]; + + if (ctx_.tx.isFieldPresent(sfAuditorEncryptionKey)) + (*sleBallot)[sfAuditorEncryptionKey] = ctx_.tx[sfAuditorEncryptionKey]; + + // Initialize each per-option counter to a deterministic Enc(0) under the + // tally key. The blinding is derived from the ballot index and option so + // every validator computes the identical genesis ciphertext, yet it is a + // valid (non-identity) EC point — Enc(0; 0) would be the point at infinity + // and fail serialization. It decrypts to 0 regardless of the randomness. + STArray tally; + for (std::uint8_t i = 0; i < ctx_.tx[sfOptionCount]; ++i) + { + auto const blinding = sha512Half(ballotKeylet.key, i); + auto const zero = + encryptAmount(0, ctx_.tx[sfTallyPublicKey], Slice(blinding.data(), blinding.size())); + if (!zero) + return tecINTERNAL; // LCOV_EXCL_LINE + + STObject entry = STObject::makeInnerObject(sfBallotOption); + entry.setFieldVL(sfEncryptedVote, *zero); + tally.push_back(std::move(entry)); + } + sleBallot->setFieldArray(sfEncryptedTally, tally); + + auto const page = + view().dirInsert(keylet::ownerDir(accountID_), ballotKeylet, describeOwnerDir(accountID_)); + if (!page) + return tecDIR_FULL; // LCOV_EXCL_LINE + (*sleBallot)[sfOwnerNode] = *page; + + increaseOwnerCount(ctx_.getApplyViewContext(), sleOwner, 1, j_); + addSponsorToLedgerEntry(ctx_.getApplyViewContext(), sleBallot); + + view().insert(sleBallot); + + // Record the open ballot on the issuance so a second one is rejected. + if (ctx_.tx.isFieldPresent(sfMPTokenIssuanceID)) + { + auto sleIssuance = view().peek(keylet::mptokenIssuance(ctx_.tx[sfMPTokenIssuanceID])); + if (!sleIssuance) + return tecINTERNAL; // LCOV_EXCL_LINE + (*sleIssuance)[sfBallotID] = ballotKeylet.key; + view().update(sleIssuance); + } + + return tesSUCCESS; +} + +void +BallotCreate::visitInvariantEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&) +{ +} + +bool +BallotCreate::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/voting/BallotDelete.cpp b/src/libxrpl/tx/transactors/voting/BallotDelete.cpp new file mode 100644 index 00000000000..ccdb9e7237a --- /dev/null +++ b/src/libxrpl/tx/transactors/voting/BallotDelete.cpp @@ -0,0 +1,111 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace xrpl { + +NotTEC +BallotDelete::preflight(PreflightContext const& ctx) +{ + if (ctx.tx[sfBallotID] == beast::kZero) + return temMALFORMED; + return tesSUCCESS; +} + +TER +BallotDelete::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; + + auto const ballotID = ctx.tx[sfBallotID]; + auto const now = ctx.view.parentCloseTime().time_since_epoch().count(); + + // A voter deletes their own BallotVote once the ballot has closed. + if (ctx.view.exists(keylet::ballotVote(ballotID, account))) + { + if (auto const sleBallot = ctx.view.read(keylet::ballot(ballotID)); + sleBallot && now < (*sleBallot)[sfCloseTime]) + return tecTOO_SOON; + return tesSUCCESS; + } + + // Otherwise the creator deletes the ballot itself. + auto const sleBallot = ctx.view.read(keylet::ballot(ballotID)); + if (!sleBallot) + return tecNO_ENTRY; + + if (sleBallot->getAccountID(sfOwner) != account) + return tecNO_PERMISSION; + + // Deletable once finalized, or once the voting window has closed. + if (!sleBallot->isFlag(lsfBallotFinalized) && now < (*sleBallot)[sfCloseTime]) + return tecTOO_SOON; + + return tesSUCCESS; +} + +TER +BallotDelete::doApply() +{ + auto const ballotID = ctx_.tx[sfBallotID]; + + auto const sleOwner = view().peek(keylet::account(accountID_)); + if (!sleOwner) + return tecINTERNAL; // LCOV_EXCL_LINE + + auto const deleteObject = [&](std::shared_ptr const& sle) -> TER { + std::uint64_t const page = (*sle)[sfOwnerNode]; + if (!view().dirRemove(keylet::ownerDir(accountID_), page, sle->key(), false)) + return tefBAD_LEDGER; // LCOV_EXCL_LINE + decreaseOwnerCountForObject(view(), sleOwner, sle, 1, j_); + view().erase(sle); + return tesSUCCESS; + }; + + // Voter deleting their own cast record. + if (auto sleVote = view().peek(keylet::ballotVote(ballotID, accountID_))) + return deleteObject(sleVote); + + // Creator deleting the ballot. + auto sleBallot = view().peek(keylet::ballot(ballotID)); + if (!sleBallot) + return tecINTERNAL; // LCOV_EXCL_LINE + return deleteObject(sleBallot); +} + +void +BallotDelete::visitInvariantEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&) +{ +} + +bool +BallotDelete::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/libxrpl/tx/transactors/voting/BallotFinalize.cpp b/src/libxrpl/tx/transactors/voting/BallotFinalize.cpp new file mode 100644 index 00000000000..cdfd585861a --- /dev/null +++ b/src/libxrpl/tx/transactors/voting/BallotFinalize.cpp @@ -0,0 +1,150 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace xrpl { + +NotTEC +BallotFinalize::preflight(PreflightContext const& ctx) +{ + auto const& results = ctx.tx.getFieldArray(sfResults); + if (results.empty() || results.size() > 8) + return temMALFORMED; + + for (auto const& entry : results) + { + if (!entry.isFieldPresent(sfBallotWeight)) + return temMALFORMED; + } + + // One decryption-correctness proof per option. + if (ctx.tx[sfZKProof].empty() || ctx.tx[sfZKProof].size() % kEcClawbackProofLength != 0) + return temMALFORMED; + + if (ctx.tx[sfZKProof].size() / kEcClawbackProofLength != results.size()) + return temMALFORMED; + + return tesSUCCESS; +} + +XRPAmount +BallotFinalize::calculateBaseFee(ReadView const& view, STTx const& tx) +{ + return Transactor::calculateBaseFee(view, tx, kConfidentialFeeMultiplier); +} + +TER +BallotFinalize::preclaim(PreclaimContext const& ctx) +{ + auto const account = ctx.tx[sfAccount]; + if (!ctx.view.exists(keylet::account(account))) + return terNO_ACCOUNT; + + auto const ballotID = ctx.tx[sfBallotID]; + auto const sleBallot = ctx.view.read(keylet::ballot(ballotID)); + if (!sleBallot) + return tecNO_ENTRY; + + if (sleBallot->isFlag(lsfBallotFinalized)) + return tecDUPLICATE; + + // Finalization is only valid once the voting window has closed. + if (ctx.view.parentCloseTime().time_since_epoch().count() < (*sleBallot)[sfCloseTime]) + return tecTOO_SOON; + + std::uint8_t const n = (*sleBallot)[sfOptionCount]; + auto const& results = ctx.tx.getFieldArray(sfResults); + if (results.size() != n) + return temMALFORMED; + + Slice const proof = ctx.tx[sfZKProof]; + if (proof.size() != static_cast(n) * kEcClawbackProofLength) + return temMALFORMED; + + STArray const& tally = sleBallot->getFieldArray(sfEncryptedTally); + if (tally.size() != n) + return tecINTERNAL; // LCOV_EXCL_LINE + + Slice const tallyKey = (*sleBallot)[sfTallyPublicKey]; + auto const ctxHash = + getBallotFinalizeContextHash(account, ballotID, ctx.tx.getSeqProxy().value()); + + // Each proof shows tally[i] decrypts to results[i] under the tally key. + for (std::uint8_t i = 0; i < n; ++i) + { + Slice const optionProof{ + proof.data() + (i * kEcClawbackProofLength), kEcClawbackProofLength}; + if (auto const ter = verifyClawbackProof( + results[i][sfBallotWeight], + optionProof, + tallyKey, + tally[i][sfEncryptedVote], + ctxHash); + !isTesSuccess(ter)) + { + return ter; + } + } + + return tesSUCCESS; +} + +TER +BallotFinalize::doApply() +{ + auto sleBallot = view().peek(keylet::ballot(ctx_.tx[sfBallotID])); + if (!sleBallot) + return tecINTERNAL; // LCOV_EXCL_LINE + + STArray results; + for (auto const& entry : ctx_.tx.getFieldArray(sfResults)) + { + STObject o = STObject::makeInnerObject(sfBallotResult); + o.setFieldU64(sfBallotWeight, entry[sfBallotWeight]); + results.push_back(std::move(o)); + } + sleBallot->setFieldArray(sfResults, results); + (*sleBallot)[sfFlags] = (*sleBallot)[sfFlags] | lsfBallotFinalized; + + view().update(sleBallot); + return tesSUCCESS; +} + +void +BallotFinalize::visitInvariantEntry( + bool, + std::shared_ptr const&, + std::shared_ptr const&) +{ +} + +bool +BallotFinalize::finalizeInvariants( + STTx const&, + TER, + XRPAmount, + ReadView const&, + beast::Journal const&) +{ + return true; +} + +} // namespace xrpl diff --git a/src/test/app/ConfidentialVoting_test.cpp b/src/test/app/ConfidentialVoting_test.cpp new file mode 100644 index 00000000000..635d4aa85f4 --- /dev/null +++ b/src/test/app/ConfidentialVoting_test.cpp @@ -0,0 +1,602 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace xrpl { +namespace test { + +using namespace jtx; +using namespace std::chrono_literals; + +class ConfidentialVoting_test : public beast::unit_test::Suite +{ + // Build a BallotCreate transaction (token mode) as raw JSON. + static json::Value + createTokenBallot( + Account const& owner, + uint192 const& issuanceID, + ballot::Keypair const& tally, + std::uint8_t optionCount, + std::uint32_t openTime, + std::uint32_t closeTime, + uint256 const& ballotID) + { + json::Value jv; + jv[jss::TransactionType] = "BallotCreate"; + jv[jss::Account] = owner.human(); + jv[sfMPTokenIssuanceID.jsonName] = to_string(issuanceID); + jv[sfDigest.jsonName] = std::string(64, 'A'); + jv[sfOptionCount.jsonName] = optionCount; + jv[sfTallyPublicKey.jsonName] = strHex(tally.pub); + jv[sfOpenTime.jsonName] = openTime; + jv[sfCloseTime.jsonName] = closeTime; + jv[sfZKProof.jsonName] = strHex(ballot::schnorrProof(tally, ballotID)); + jv[jss::Fee] = "1000"; // covers the confidential fee multiplier + return jv; + } + + // Build a BallotCastVote transaction as raw JSON. + static json::Value + castVote(Account const& voter, uint256 const& ballotID, BallotCast const& cast) + { + json::Value jv; + jv[jss::TransactionType] = "BallotCastVote"; + jv[jss::Account] = voter.human(); + jv[sfBallotID.jsonName] = to_string(ballotID); + jv[sfEncryptedVotes.jsonName] = + ballotOptionArray(cast.ciphertexts, &cast.commitments, &cast.linkageProofs); + jv[sfZKProof.jsonName] = strHex(cast.proof); + jv[sfBlindingFactor.jsonName] = strHex(cast.blinding); + jv[jss::Fee] = "1000"; // covers the confidential fee multiplier + return jv; + } + + // Build a BallotFinalize transaction as raw JSON. + static json::Value + finalize( + Account const& authority, + uint256 const& ballotID, + std::vector const& results, + Buffer const& proof) + { + json::Value jv; + jv[jss::TransactionType] = "BallotFinalize"; + jv[jss::Account] = authority.human(); + jv[sfBallotID.jsonName] = to_string(ballotID); + json::Value arr(json::ValueType::Array); + for (auto const r : results) + { + json::Value inner; + // sfBallotWeight is a UINT64; its JSON string form is parsed as hex. + std::stringstream ss; + ss << std::hex << r; + inner[sfBallotWeight.jsonName] = ss.str(); + json::Value elem; + elem[sfBallotResult.jsonName] = inner; + arr.append(elem); + } + jv[sfResults.jsonName] = arr; + jv[sfZKProof.jsonName] = strHex(proof); + jv[jss::Fee] = "1000"; // covers the confidential fee multiplier + return jv; + } + + static json::Value + deleteBallot(Account const& account, uint256 const& ballotID) + { + json::Value jv; + jv[jss::TransactionType] = "BallotDelete"; + jv[jss::Account] = account.human(); + jv[sfBallotID.jsonName] = to_string(ballotID); + return jv; + } + + void + testAmendmentDisabled() + { + testcase("Amendment disabled"); + Env env{*this, testableAmendments() - featureConfidentialVoting}; + Account const alice{"alice"}; + env.fund(XRP(10000), alice); + env.close(); + + MPTTester mpt(env, alice, {.fund = false}); + mpt.create({.flags = tfMPTCanTransfer}); + + auto const tally = ballot::generateKeypair(); + auto const seq = env.seq(alice); + auto const ballotID = keylet::ballot(alice.id(), seq).key; + auto jv = createTokenBallot(alice, mpt.issuanceID(), tally, 2, 0, 0xFFFFFFFFu, ballotID); + env(jv, Ter(temDISABLED)); + } + + void + testTokenHappyPath() + { + testcase("Token mode happy path"); + Env env{*this}; + Account const alice{"alice"}; // issuer + tally authority + Account const bob{"bob"}; // voter + env.fund(XRP(10000), alice, bob); + env.close(); + + MPTTester mpt(env, alice, {.holders = {bob}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + mpt.authorize({.account = bob}); + mpt.pay(alice, bob, 100); + env.close(); + + auto const tally = ballot::generateKeypair(); + + // Create with a finite voting window. + auto const nowSecs = env.now().time_since_epoch().count(); + auto const createSeq = env.seq(alice); + auto const ballotID = keylet::ballot(alice.id(), createSeq).key; + env(createTokenBallot(alice, mpt.issuanceID(), tally, 2, 0, nowSecs + 1000, ballotID)); + env.close(); + + // Cast: bob assigns all 100 weight to option 0. + auto const castSeq = env.seq(bob); + auto const castCtx = getBallotCastContextHash(bob.id(), ballotID, castSeq); + auto const cast = makeBallotCast(tally, 2, /*choice=*/0, /*weight=*/100, castCtx); + env(castVote(bob, ballotID, cast)); + env.close(); + + // VoteCount incremented and lock recorded. + { + auto const sle = env.le(keylet::ballot(ballotID)); + BEAST_EXPECT(sle && (*sle)[sfVoteCount] == 1); + auto const mptSle = env.le(keylet::mptoken(mpt.issuanceID(), bob.id())); + BEAST_EXPECT(mptSle && (*mptSle)[~sfVoteLockedAmount].value_or(0) == 100); + } + + // Close the voting window. + env.close(2000s); + + // Finalize: read the on-ledger tally and prove its decryption. + auto const sle = env.le(keylet::ballot(ballotID)); + BEAST_EXPECT(sle); + auto const& tallyArr = sle->getFieldArray(sfEncryptedTally); + std::vector results; + std::vector proofBufs; + auto const finSeq = env.seq(alice); + auto const finCtx = getBallotFinalizeContextHash(alice.id(), ballotID, finSeq); + for (auto const& entry : tallyArr) + { + Slice const ct = entry[sfEncryptedVote]; + auto const amt = ballot::decrypt(ct, tally.priv, 1000); + BEAST_EXPECT(amt.has_value()); + results.push_back(*amt); + proofBufs.push_back(ballot::decryptionProof(tally, *amt, ct, finCtx)); + } + BEAST_EXPECT(results.size() == 2 && results[0] == 100 && results[1] == 0); + + Buffer proof(proofBufs.size() * kEcClawbackProofLength); + for (std::size_t i = 0; i < proofBufs.size(); ++i) + std::memcpy( + proof.data() + (i * kEcClawbackProofLength), + proofBufs[i].data(), + kEcClawbackProofLength); + + env(finalize(alice, ballotID, results, proof)); + env.close(); + + { + auto const finSle = env.le(keylet::ballot(ballotID)); + BEAST_EXPECT(finSle && finSle->isFlag(lsfBallotFinalized)); + auto const& res = finSle->getFieldArray(sfResults); + BEAST_EXPECT( + res.size() == 2 && res[0][sfBallotWeight] == 100 && res[1][sfBallotWeight] == 0); + } + + // Lock released after close: bob can now transfer. + mpt.pay(bob, alice, 50); + + // Creator reclaims the ballot reserve. + env(deleteBallot(alice, ballotID)); + env.close(); + BEAST_EXPECT(!env.le(keylet::ballot(ballotID))); + } + + void + testCastFailures() + { + testcase("Cast failures"); + Env env{*this}; + Account const alice{"alice"}; + Account const bob{"bob"}; + Account const carol{"carol"}; // no MPToken + env.fund(XRP(10000), alice, bob, carol); + env.close(); + + MPTTester mpt(env, alice, {.holders = {bob}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + mpt.authorize({.account = bob}); + mpt.pay(alice, bob, 100); + env.close(); + + auto const tally = ballot::generateKeypair(); + + // A ballot that is not yet open. Uses a separate issuance so it does not + // collide with the open ballot below (one open ballot per issuance). + MPTTester mpt2(env, alice, {.holders = {bob}, .fund = false}); + mpt2.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + mpt2.authorize({.account = bob}); + mpt2.pay(alice, bob, 100); + env.close(); + + auto const nowSecs = env.now().time_since_epoch().count(); + auto const futureSeq = env.seq(alice); + auto const futureBallot = keylet::ballot(alice.id(), futureSeq).key; + env(createTokenBallot( + alice, mpt2.issuanceID(), tally, 2, nowSecs + 10000, nowSecs + 20000, futureBallot)); + env.close(); + { + auto const castSeq = env.seq(bob); + auto const cast = makeBallotCast( + tally, 2, 0, 100, getBallotCastContextHash(bob.id(), futureBallot, castSeq)); + env(castVote(bob, futureBallot, cast), Ter(tecBALLOT_NOT_OPEN)); + env.close(); + } + + // An open ballot for the remaining cases. + auto const openSeq = env.seq(alice); + auto const ballotID = keylet::ballot(alice.id(), openSeq).key; + env(createTokenBallot(alice, mpt.issuanceID(), tally, 2, 0, nowSecs + 5000, ballotID)); + env.close(); + + // Malformed proof: corrupt the range proof. + { + auto const castSeq = env.seq(bob); + auto cast = makeBallotCast( + tally, 2, 0, 100, getBallotCastContextHash(bob.id(), ballotID, castSeq)); + cast.proof.data()[0] ^= 0xFF; + env(castVote(bob, ballotID, cast), Ter(tecBAD_PROOF)); + env.close(); + } + + // Ineligible: carol holds no MPToken. + { + auto const castSeq = env.seq(carol); + auto const cast = makeBallotCast( + tally, 2, 0, 1, getBallotCastContextHash(carol.id(), ballotID, castSeq)); + env(castVote(carol, ballotID, cast), Ter(tecNO_ENTRY)); + env.close(); + } + + // Happy cast, then a double-cast is rejected. + { + auto const castSeq = env.seq(bob); + auto const cast = makeBallotCast( + tally, 2, 0, 100, getBallotCastContextHash(bob.id(), ballotID, castSeq)); + env(castVote(bob, ballotID, cast)); + env.close(); + + auto const castSeq2 = env.seq(bob); + auto const cast2 = makeBallotCast( + tally, 2, 0, 100, getBallotCastContextHash(bob.id(), ballotID, castSeq2)); + env(castVote(bob, ballotID, cast2), Ter(tecBALLOT_VOTED)); + env.close(); + } + + // Cast after close is rejected. + env.close(6000s); + { + Account const dave{"dave"}; + env.fund(XRP(10000), dave); + mpt.authorize({.account = dave}); + mpt.pay(alice, dave, 10); + auto const castSeq = env.seq(dave); + auto const cast = makeBallotCast( + tally, 2, 0, 10, getBallotCastContextHash(dave.id(), ballotID, castSeq)); + env(castVote(dave, ballotID, cast), Ter(tecBALLOT_CLOSED)); + env.close(); + } + } + + void + testFinalizeFailures() + { + testcase("Finalize failures"); + Env env{*this}; + Account const alice{"alice"}; + Account const bob{"bob"}; + env.fund(XRP(10000), alice, bob); + env.close(); + + MPTTester mpt(env, alice, {.holders = {bob}, .fund = false}); + mpt.create({.flags = tfMPTCanTransfer | tfMPTCanLock}); + mpt.authorize({.account = bob}); + mpt.pay(alice, bob, 100); + env.close(); + + auto const tally = ballot::generateKeypair(); + auto const nowSecs = env.now().time_since_epoch().count(); + auto const createSeq = env.seq(alice); + auto const ballotID = keylet::ballot(alice.id(), createSeq).key; + env(createTokenBallot(alice, mpt.issuanceID(), tally, 2, 0, nowSecs + 3000, ballotID)); + env.close(); + + auto const castSeq = env.seq(bob); + auto const cast = + makeBallotCast(tally, 2, 0, 100, getBallotCastContextHash(bob.id(), ballotID, castSeq)); + env(castVote(bob, ballotID, cast)); + env.close(); + + // Finalize before close is rejected. + { + auto const sle = env.le(keylet::ballot(ballotID)); + auto const& tallyArr = sle->getFieldArray(sfEncryptedTally); + auto const finSeq = env.seq(alice); + auto const finCtx = getBallotFinalizeContextHash(alice.id(), ballotID, finSeq); + std::vector results{100, 0}; + Buffer proof(2 * kEcClawbackProofLength); + for (std::size_t i = 0; i < 2; ++i) + { + auto p = ballot::decryptionProof( + tally, results[i], tallyArr[i][sfEncryptedVote], finCtx); + std::memcpy( + proof.data() + (i * kEcClawbackProofLength), p.data(), kEcClawbackProofLength); + } + env(finalize(alice, ballotID, results, proof), Ter(tecTOO_SOON)); + env.close(); + } + + // Close the window. + env.close(4000s); + + // Finalize with wrong results is rejected: proofs are generated for the + // true counts (100, 0), but the submitted results claim (99, 0), so the + // decryption-correctness check fails. + { + auto const sle = env.le(keylet::ballot(ballotID)); + auto const& tallyArr = sle->getFieldArray(sfEncryptedTally); + auto const finSeq = env.seq(alice); + auto const finCtx = getBallotFinalizeContextHash(alice.id(), ballotID, finSeq); + std::vector const trueResults{100, 0}; + Buffer proof(2 * kEcClawbackProofLength); + for (std::size_t i = 0; i < 2; ++i) + { + auto p = ballot::decryptionProof( + tally, trueResults[i], tallyArr[i][sfEncryptedVote], finCtx); + std::memcpy( + proof.data() + (i * kEcClawbackProofLength), p.data(), kEcClawbackProofLength); + } + std::vector const wrong{99, 0}; + env(finalize(alice, ballotID, wrong, proof), Ter(tecBAD_PROOF)); + env.close(); + } + } + + // Low-level check that the compact_standard vote-linkage proof (with the + // canonical vacuous balance witness) verifies, and rejects a commitment to a + // different value than the ciphertext. + void + testLinkageRoundTrip() + { + testcase("Vote linkage compact_standard round-trip"); + auto* const ctx = mpt_secp256k1_context(); + + auto const tally = ballot::generateKeypair(); + std::uint64_t const m = 42; + auto const r = ballot::randomScalar(); + + // ct = Enc(m; r) under the tally key; split into C1, C2. + auto const ctBuf = ballot::encrypt(m, tally.pub, r); + auto const ct = makeEcPair(ctBuf); + BEAST_EXPECT(ct.has_value()); + + secp256k1_pubkey tallyPub; + BEAST_EXPECT( + secp256k1_ec_pubkey_parse(ctx, &tallyPub, tally.pub.data(), tally.pub.size()) == 1); + + // PC_m = m*G + r*H (blinded by the ElGamal randomness r). + auto const pcmBuf = ballot::pedersen(m, r); + secp256k1_pubkey pcm; + BEAST_EXPECT(secp256k1_ec_pubkey_parse(ctx, &pcm, pcmBuf.data(), pcmBuf.size()) == 1); + + // Vacuous balance witness: sk_A = 1, pk_A = G, b = 0, B1 = B2 = G, + // PC_b = rho_b*H. Constrains nothing about the vote. + Buffer skA(kEcScalarLength); // 0x00..01 + skA.data()[kEcScalarLength - 1] = 1; + secp256k1_pubkey pkA; + BEAST_EXPECT(secp256k1_ec_pubkey_create(ctx, &pkA, skA.data()) == 1); + secp256k1_pubkey const B1 = pkA; + secp256k1_pubkey const B2 = pkA; + auto const rhoB = ballot::randomScalar(); + auto const pcbBuf = ballot::pedersen(0, rhoB); + secp256k1_pubkey pcb; + BEAST_EXPECT(secp256k1_ec_pubkey_parse(ctx, &pcb, pcbBuf.data(), pcbBuf.size()) == 1); + + uint256 const ctxHash = sha512Half(std::uint16_t(0x5A5A), m); + + std::array proof{}; + BEAST_EXPECT( + secp256k1_compact_standard_prove( + ctx, + proof.data(), + m, + /*balance=*/0, + r.data(), + skA.data(), + rhoB.data(), + /*n=*/1, + &ct->c1, + &ct->c2, + &tallyPub, + &pcm, + &pkA, + &pcb, + &B1, + &B2, + ctxHash.data()) == 1); + + BEAST_EXPECT( + secp256k1_compact_standard_verify( + ctx, + proof.data(), + 1, + &ct->c1, + &ct->c2, + &tallyPub, + &pcm, + &pkA, + &pcb, + &B1, + &B2, + ctxHash.data()) == 1); + + // Tamper: PC_m commits to a different value than the ciphertext. + auto const pcmBadBuf = ballot::pedersen(m + 1, r); + secp256k1_pubkey pcmBad; + BEAST_EXPECT( + secp256k1_ec_pubkey_parse(ctx, &pcmBad, pcmBadBuf.data(), pcmBadBuf.size()) == 1); + BEAST_EXPECT( + secp256k1_compact_standard_verify( + ctx, + proof.data(), + 1, + &ct->c1, + &ct->c2, + &tallyPub, + &pcmBad, + &pkA, + &pcb, + &B1, + &B2, + ctxHash.data()) == 0); + } + + void + testCredentialHappyPath() + { + testcase("Credential mode (1p1v) happy path"); + Env env{*this}; + Account const issuer{"issuer"}; + Account const member{"member"}; + env.fund(XRP(10000), issuer, member); + env.close(); + + // Permissioned domain owned by issuer, accepting a MEMBER credential. + std::string const credType = "MEMBER"; + pdomain::Credentials const accepted{{.issuer = issuer, .credType = credType}}; + env(pdomain::setTx(issuer, accepted)); + auto const domainID = pdomain::getNewDomain(env.meta()); + env.close(); + + // Issue and accept the credential for the member. + env(credentials::create(member, issuer, credType)); + env.close(); + env(credentials::accept(member, issuer, credType)); + env.close(); + + auto const tally = ballot::generateKeypair(); + auto const nowSecs = env.now().time_since_epoch().count(); + auto const createSeq = env.seq(issuer); + auto const ballotID = keylet::ballot(issuer.id(), createSeq).key; + + json::Value jv; + jv[jss::TransactionType] = "BallotCreate"; + jv[jss::Account] = issuer.human(); + jv[sfDomainID.jsonName] = to_string(domainID); + jv[sfDigest.jsonName] = std::string(64, 'A'); + jv[sfOptionCount.jsonName] = 2; + jv[sfTallyPublicKey.jsonName] = strHex(tally.pub); + jv[sfOpenTime.jsonName] = 0; + jv[sfCloseTime.jsonName] = nowSecs + 1000; + jv[sfZKProof.jsonName] = strHex(ballot::schnorrProof(tally, ballotID)); + jv[jss::Fee] = "1000"; // covers the confidential fee multiplier + env(jv); + env.close(); + + // The member holds an accepted credential satisfying the domain, so + // weight is 1. The linkage proof makes the encrypted cast verifiable + // under the third-party tally key (no token, no lock). + auto const castSeq = env.seq(member); + auto const cast = makeBallotCast( + tally, + 2, + /*choice=*/0, + /*weight=*/1, + getBallotCastContextHash(member.id(), ballotID, castSeq)); + env(castVote(member, ballotID, cast)); + env.close(); + + { + auto const sle = env.le(keylet::ballot(ballotID)); + BEAST_EXPECT(sle && (*sle)[sfVoteCount] == 1); + } + + // Close the window and finalize with per-option decryption proofs. + env.close(2000s); + auto const sle = env.le(keylet::ballot(ballotID)); + BEAST_EXPECT(sle); + auto const& tallyArr = sle->getFieldArray(sfEncryptedTally); + std::vector results; + std::vector proofBufs; + auto const finSeq = env.seq(issuer); + auto const finCtx = getBallotFinalizeContextHash(issuer.id(), ballotID, finSeq); + for (auto const& entry : tallyArr) + { + Slice const ct = entry[sfEncryptedVote]; + auto const amt = ballot::decrypt(ct, tally.priv, 100); + BEAST_EXPECT(amt.has_value()); + results.push_back(*amt); + proofBufs.push_back(ballot::decryptionProof(tally, *amt, ct, finCtx)); + } + BEAST_EXPECT(results.size() == 2 && results[0] == 1 && results[1] == 0); + + Buffer proof(proofBufs.size() * kEcClawbackProofLength); + for (std::size_t i = 0; i < proofBufs.size(); ++i) + std::memcpy( + proof.data() + (i * kEcClawbackProofLength), + proofBufs[i].data(), + kEcClawbackProofLength); + + env(finalize(issuer, ballotID, results, proof)); + env.close(); + + auto const finSle = env.le(keylet::ballot(ballotID)); + BEAST_EXPECT(finSle && finSle->isFlag(lsfBallotFinalized)); + auto const& res = finSle->getFieldArray(sfResults); + BEAST_EXPECT(res.size() == 2 && res[0][sfBallotWeight] == 1 && res[1][sfBallotWeight] == 0); + } + +public: + void + run() override + { + testAmendmentDisabled(); + testTokenHappyPath(); + testCastFailures(); + testFinalizeFailures(); + testLinkageRoundTrip(); + testCredentialHappyPath(); + } +}; + +BEAST_DEFINE_TESTSUITE(ConfidentialVoting, app, xrpl); + +} // namespace test +} // namespace xrpl diff --git a/src/test/jtx/Ballot.h b/src/test/jtx/Ballot.h new file mode 100644 index 00000000000..4bdff7c2760 --- /dev/null +++ b/src/test/jtx/Ballot.h @@ -0,0 +1,339 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace xrpl { +namespace test { +namespace jtx { + +// Standalone confidential-voting crypto helpers for tests. Ballots use a tally +// key that no ledger account owns, so these build proofs directly against the +// merged mpt-crypto primitives rather than the per-account MPTTester helpers. +namespace ballot { + +struct Keypair +{ + Buffer priv; // 32 bytes + Buffer pub; // 33 bytes +}; + +inline Keypair +generateKeypair() +{ + Keypair kp{Buffer(kEcPrivKeyLength), Buffer(kEcPubKeyLength)}; + if (mpt_generate_keypair(kp.priv.data(), kp.pub.data()) != 0) + Throw("mpt_generate_keypair failed"); + return kp; +} + +inline Buffer +randomScalar() +{ + Buffer out(kEcBlindingFactorLength); + if (mpt_generate_blinding_factor(out.data()) != 0) + Throw("mpt_generate_blinding_factor failed"); + return out; +} + +inline Buffer +encrypt(std::uint64_t value, Slice const& pubKey, Slice const& randomness) +{ + auto out = encryptAmount(value, pubKey, randomness); + if (!out) + Throw("encryptAmount failed"); + return *out; +} + +inline Buffer +pedersen(std::uint64_t value, Slice const& blinding) +{ + Buffer out(kEcPedersenCommitmentLength); + if (mpt_get_pedersen_commitment(value, blinding.data(), out.data()) != 0) + Throw("mpt_get_pedersen_commitment failed"); + return out; +} + +// R = a + b (mod curve order). +inline Buffer +scalarAdd(Slice const& a, Slice const& b) +{ + Buffer out(kEcScalarLength); + secp256k1_mpt_scalar_add(out.data(), a.data(), b.data()); + return out; +} + +// Aggregated Bulletproof range proof over m committed option values. +inline Buffer +rangeProof( + std::vector const& values, + std::vector const& blindings, + uint256 const& contextHash) +{ + auto* const ctx = mpt_secp256k1_context(); + secp256k1_pubkey h; + secp256k1_mpt_get_h_generator(ctx, &h); + + std::vector flat(values.size() * kEcScalarLength); + for (std::size_t i = 0; i < blindings.size(); ++i) + std::memcpy(flat.data() + (i * kEcScalarLength), blindings[i].data(), kEcScalarLength); + + Buffer proof(kEcDoubleBulletproofLength * values.size()); + std::size_t proofLen = proof.size(); + if (secp256k1_bulletproof_prove_agg( + ctx, + proof.data(), + &proofLen, + values.data(), + flat.data(), + values.size(), + &h, + contextHash.data()) == 0) + { + Throw("secp256k1_bulletproof_prove_agg failed"); + } + + return Buffer(proof.data(), proofLen); +} + +// Schnorr proof of possession of the tally private key, bound to a context. +inline Buffer +schnorrProof(Keypair const& kp, uint256 const& contextHash) +{ + Buffer proof(kEcSchnorrProofLength); + if (mpt_get_convert_proof(kp.pub.data(), kp.priv.data(), contextHash.data(), proof.data()) != 0) + Throw("mpt_get_convert_proof failed"); + return proof; +} + +// Decryption-correctness (clawback) proof: proves ciphertext decrypts to amount +// under the tally key. +inline Buffer +decryptionProof( + Keypair const& kp, + std::uint64_t amount, + Slice const& ciphertext, + uint256 const& contextHash) +{ + Buffer proof(kEcClawbackProofLength); + if (mpt_get_clawback_proof( + kp.priv.data(), + kp.pub.data(), + contextHash.data(), + amount, + ciphertext.data(), + proof.data()) != 0) + { + Throw("mpt_get_clawback_proof failed"); + } + return proof; +} + +inline std::optional +decrypt(Slice const& ciphertext, Slice const& priv, std::uint64_t high) +{ + std::uint64_t out = 0; + if (mpt_decrypt_amount(ciphertext.data(), priv.data(), &out, 0, high) != 0) + return std::nullopt; + return out; +} + +// Per-option ciphertext<->commitment linkage proof. Proves each mirror +// ciphertext (tally first, then optional auditor/voter, all sharing the option +// randomness r) encrypts the value committed in PC_m = value*G + r*H. Uses the +// canonical vacuous balance witness (sk_A = 1, rho_b = 1) the verifier expects. +inline Buffer +linkageProof( + std::uint64_t value, + Slice const& r, + std::vector const& pubKeys, + std::vector const& ciphertexts, // one 66-byte ct per key, shared C1 + Slice const& commitment, + uint256 const& contextHash) +{ + auto* const ctx = mpt_secp256k1_context(); + std::size_t const n = pubKeys.size(); + + auto const first = makeEcPair(ciphertexts[0]); + if (!first) + Throw("linkageProof: bad tally ciphertext"); + secp256k1_pubkey const c1 = first->c1; + + std::vector c2v(n); + std::vector pkv(n); + for (std::size_t i = 0; i < n; ++i) + { + auto const pair = makeEcPair(ciphertexts[i]); + if (!pair) + Throw("linkageProof: bad mirror ciphertext"); + c2v[i] = pair->c2; + if (secp256k1_ec_pubkey_parse(ctx, &pkv[i], pubKeys[i].data(), pubKeys[i].size()) != 1) + Throw("linkageProof: bad pubkey"); + } + + secp256k1_pubkey pcm; + if (secp256k1_ec_pubkey_parse(ctx, &pcm, commitment.data(), commitment.size()) != 1) + Throw("linkageProof: bad commitment"); + + Buffer skA(kEcScalarLength); + skA.data()[kEcScalarLength - 1] = 1; + Buffer rhoB(kEcScalarLength); + rhoB.data()[kEcScalarLength - 1] = 1; + secp256k1_pubkey pkA; + if (secp256k1_ec_pubkey_create(ctx, &pkA, skA.data()) != 1) + Throw("linkageProof: pk_A"); + secp256k1_pubkey pcb; // PC_b = 0*G + 1*H = H + if (secp256k1_mpt_get_h_generator(ctx, &pcb) != 1) + Throw("linkageProof: H"); + + Buffer proof(kEcSendSigmaProofLength); + if (secp256k1_compact_standard_prove( + ctx, + proof.data(), + value, + /*balance=*/0, + r.data(), + skA.data(), + rhoB.data(), + n, + &c1, + c2v.data(), + pkv.data(), + &pcm, + &pkA, + &pcb, + &pkA, // B1 = G + &pkA, // B2 = G + contextHash.data()) != 1) + { + Throw("secp256k1_compact_standard_prove failed"); + } + return proof; +} + +} // namespace ballot + +// A cast's per-option payload, ready to be serialized into a BallotCastVote. +struct BallotCast +{ + std::vector ciphertexts; // one ElGamal ciphertext per option + std::vector commitments; // one Pedersen commitment per option + std::vector linkageProofs; // one compact-sigma proof per option + std::vector auditorCiphertexts; // empty unless auditor configured + std::vector voterCiphertexts; // empty unless voter-recoverable + Buffer proof; // aggregated range proof + Buffer blinding; // aggregate ElGamal randomness R +}; + +// Build a cast that assigns the full weight to option `choice` and zero to the +// rest, with real range proof and aggregate randomness. +inline BallotCast +makeBallotCast( + ballot::Keypair const& tally, + std::uint8_t optionCount, + std::uint8_t choice, + std::uint64_t weight, + uint256 const& contextHash, + std::optional const& auditor = std::nullopt, + std::optional const& voter = std::nullopt) +{ + BallotCast cast; + std::vector values(optionCount, 0); + values[choice] = weight; + + // A single per-option scalar r is the ElGamal randomness AND the Pedersen + // blinding, so the commitment PC = value*G + r*H links directly to the + // ciphertext for the compact-sigma linkage proof. + std::vector blindings; + Buffer aggregate(kEcScalarLength); // zero-initialized + + for (std::uint8_t i = 0; i < optionCount; ++i) + { + auto const r = ballot::randomScalar(); + blindings.push_back(r); + + cast.commitments.push_back(ballot::pedersen(values[i], r)); + cast.ciphertexts.push_back(ballot::encrypt(values[i], tally.pub, r)); + if (auditor) + cast.auditorCiphertexts.push_back(ballot::encrypt(values[i], auditor->pub, r)); + if (voter) + cast.voterCiphertexts.push_back(ballot::encrypt(values[i], voter->pub, r)); + + aggregate = ballot::scalarAdd(aggregate, r); + } + + cast.blinding = aggregate; + cast.proof = ballot::rangeProof(values, blindings, contextHash); + + // Per-option linkage proof over every mirror key present (tally first). + for (std::uint8_t i = 0; i < optionCount; ++i) + { + std::vector keys{tally.pub}; + std::vector cts{cast.ciphertexts[i]}; + if (auditor) + { + keys.push_back(auditor->pub); + cts.push_back(cast.auditorCiphertexts[i]); + } + if (voter) + { + keys.push_back(voter->pub); + cts.push_back(cast.voterCiphertexts[i]); + } + cast.linkageProofs.push_back( + ballot::linkageProof( + values[i], blindings[i], keys, cts, cast.commitments[i], contextHash)); + } + return cast; +} + +// Serialize a ciphertext vector as a JSON array of BallotOption inner objects. +inline json::Value +ballotOptionArray( + std::vector const& ciphertexts, + std::vector const* commitments, + std::vector const* proofs = nullptr) +{ + json::Value arr(json::ValueType::Array); + for (std::size_t i = 0; i < ciphertexts.size(); ++i) + { + json::Value inner; + inner[sfEncryptedVote.jsonName] = strHex(ciphertexts[i]); + if (commitments) + inner[sfAmountCommitment.jsonName] = strHex((*commitments)[i]); + if (proofs) + inner[sfZKProof.jsonName] = strHex((*proofs)[i]); + json::Value elem; + elem[sfBallotOption.jsonName] = inner; + arr.append(elem); + } + return arr; +} + +} // namespace jtx +} // namespace test +} // namespace xrpl diff --git a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp index 784be779bbe..e18513a54cf 100644 --- a/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp +++ b/src/xrpld/rpc/handlers/ledger/LedgerEntry.cpp @@ -518,6 +518,50 @@ parseLoan( return keylet::loan(*id, *seq).key; } +static std::expected +parseBallot( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName, "hex string"); + } + + auto const owner = LedgerEntryHelpers::requiredAccountID(params, jss::owner, "malformedOwner"); + if (!owner) + return std::unexpected(owner.error()); + auto const seq = LedgerEntryHelpers::requiredUInt32(params, jss::seq, "malformedSeq"); + if (!seq) + return std::unexpected(seq.error()); + + return keylet::ballot(*owner, *seq).key; +} + +static std::expected +parseBallotVote( + json::Value const& params, + json::StaticString const fieldName, + [[maybe_unused]] unsigned const apiVersion) +{ + if (!params.isObject()) + { + return parseObjectID(params, fieldName, "hex string"); + } + + auto const ballotID = + LedgerEntryHelpers::requiredUInt256(params, jss::ballot_id, "malformedBallotID"); + if (!ballotID) + return std::unexpected(ballotID.error()); + auto const account = + LedgerEntryHelpers::requiredAccountID(params, jss::account, "malformedAccount"); + if (!account) + return std::unexpected(account.error()); + + return keylet::ballotVote(*ballotID, *account).key; +} + static std::expected parseMPToken( json::Value const& params,