Skip to content

dao-vote: emit the UserVote log at vote time so individual votes are fully observable #2068

Description

@Maxnflaxl

Summary

The DaoVote shader records each voter's ballot + stake in a UserVote log (Events::UserVote), but the log is emitted lazily — only when a user next interacts with the contract in a later epoch — instead of when the vote is cast. Consequently the per-voter vote history reconstructable from logs is incomplete and eventually-consistent, even though the aggregate variant tally is always correct. Votes by users who cast a ballot and then never interact again are never logged at all.

This makes it impossible for explorers/indexers to show a complete or timely "who voted what" for a proposal.

Root cause

EmitVotes is only ever called from MyUser::LoadPlus (bvm/Shaders/dao-vote/contract.cpp:266-296), which runs when a user is loaded and their stored epoch is older than the current one:

// contract.cpp:266-296  (MyUser::LoadPlus)
uint32_t nSize = Env::LoadVar(&m_Key, sizeof(m_Key), this, sizeof(UserMax), KeyTag::Internal);
if (nSize) {
    if (m_iEpoch == s.m_Current.m_iEpoch)
        return;                          // same epoch → emit NOTHING
    EmitVotes(nSize - sizeof(User));     // older epoch → emit the PREVIOUS epoch's votes, now
    ...
}
...
Env::Memset(m_pVotes, s_NoVote, sizeof(*m_pVotes) * s.m_Current.m_Proposals);  // votes reset every epoch

Meanwhile the ballot is applied to the aggregate tally eagerly, at vote time, in AdjustVotes (contract.cpp:178, p.m_pVariant[n1] += v1). So:

  1. You vote in epoch N (Method_5) → your stake hits the proposal's variant tally immediately. No log yet.
  2. Your UserVote log for epoch N is emitted only the next time you touch the contract in epoch > N (LoadPlus flushes it, then resets you).
  3. If you vote in epoch N and never interact again, your log is never emitted.

The Vote path itself has no emission:

// contract.cpp:403-421  (BEAM_EXPORT void Method_5 — Vote)
MyUser u;
u.LoadPlus(s, r.m_pkUser);                                        // may flush a PRIOR epoch's log
Amount val = u.m_Stake;
Env::Halt_if(!val || (u.m_VoteCounter > r.m_VoteCounter));
s.AdjustVotes(u, reinterpret_cast<const uint8_t*>(&r + 1), val);  // this vote → tally
u.m_VoteCounter = r.m_VoteCounter;
u.Save(s);
// <-- no EmitVotes for the vote just cast

Impact (mainnet)

DaoVote CID 64c8bbbd7c411bd7f9f9a0fd4ca678c581350b5b5ce0b0c055033c7e8f69e555, proposal 0004 (epoch 93): aggregate tally is 100K No + 2.56M Yes (2.66M), but only 5 UserVote logs exist for that epoch (4 Yes + 1 no-vote, ≈1.15M BEAMX). The "No" voter and ~1.5M of the Yes stake have no log. In general, for any proposal Σ(logged vote stake) ≤ tally, and the gap is large for recent epochs (most voters haven't re-interacted yet) and permanent for voters who went dormant.

The UserVote log is what dao-vote/app.cpp's view_votes and external indexers read to present individual votes, so this directly limits governance transparency.

Proposed change

Emit the log eagerly, in the vote path, right after the ballot is applied — one added line in Method_5:

s.AdjustVotes(u, reinterpret_cast<const uint8_t*>(&r + 1), val);
u.m_VoteCounter = r.m_VoteCounter;
u.EmitVotes(s.m_Current.m_Proposals);   // <-- log this vote at cast time
u.Save(s);

(u.m_pVotes already holds the current ballot after AdjustVotes, and u.m_iProposal0 was set for the current epoch in LoadPlus, so EmitVotes emits the correct key/value.)

For full weight accuracy, the same one-liner could be added after the stake-driven AdjustVotes in Method_4 (MoveFunds, contract.cpp:389) so a stake change that re-weights an existing vote re-emits the updated log.

The lazy emission in LoadPlus then becomes redundant and can be removed.

Trade-offs

  • A user who re-votes within an epoch (or changes stake) emits multiple logs for the same (pk, m_ID_0_be) key at different heights. That's already the natural shape for indexers — dedupe to the latest entry per (voter, epoch) by height (the current lazy scheme also produces at most one settled entry, so consumers already pick a single row per voter+epoch).
  • Net log volume goes from ~one-per-user-per-epoch to ~one-per-vote-action, in exchange for completeness and timeliness.

Scope / caveats

  • Touches only Events::UserVote emission — no change to tallies, dividends, stake accounting, or any consensus-relevant state.
  • Requires an Upgradable3 contract upgrade to take effect, and only fixes votes cast after the upgrade. Pre-upgrade per-voter history is unrecoverable (each epoch wipes per-user votes; only the aggregate tally is retained for closed proposals).
  • The current lazy design is presumably a deliberate gas optimization — the BVM can't iterate all users to flush at epoch close, and the contract doesn't need these logs for its own operation. This is an observability improvement, not a correctness fix; raising it in case fuller on-chain vote transparency is considered worthwhile.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions