Docs / Devs / Native Auto-Voting System

Native Auto-Voting System

This guide is for teams building VeBetterDAO voting automation in their own dapps. It focuses on a production-friendly state engine that moves managed wallets from "eligible to vote" to "auto-voting enabled", then relies on relayers for round-by-round vote execution.

The document is intentionally written so you can feed it to AI coding agents and generate an implementation quickly, while keeping security-critical details abstracted.

Ground rules for fair play between apps

Every app implementing this model should follow these baseline rules:

  • Be clear and transparent that your app is casting votes for your app using user-controlled voting funds.
  • Let users opt out at any time (persist intent in your DB; apply on-chain disable via your lifecycle worker — see User voting preferences & opt-out below).
  • Let users change which X2Earn apps receive their auto-voting split (same deferred-on-chain pattern).

User voting preferences & opt-out

Production pattern on Green Ambassador:

  1. Where users edit: A preferences screen (for example /account/voting-preferences) saves only PostgreSQL — no immediate chain transaction. Users pick up to 15 X2Earn app ids (bytes32 hex); your platform app id stays mandatory and fixed in the UI.
  2. What you store (see Data model):
    • voting_pref_app_ids — desired list for setUserVotingPreferences.
    • voting_pref_opt_out — user wants auto-voting off even if it is still enabled on chain.
    • voting_pref_synced_app_ids — last app list successfully written on chain by your automation (used to detect drift).
    • voting_pref_updated_at — audit only.
  3. Allowlist: Gate the picker with an admin setting (here settings.voting_pref_allowed_app_ids) merged with live metadata from X2EarnApps, so users cannot pick arbitrary contract ids.
  4. When chain updates: The hourly cron compares DB intent to live chain state:
    • native_sync_prefs: Auto-voting already on, but voting_pref_app_ids ≠ last synced list → broadcast setUserVotingPreferences only, then stamp voting_pref_synced_app_ids.
    • native_opt_out: User opted out while isUserAutoVotingEnabled is still true → broadcast toggleAutoVoting (same single-clause shape as re-enrol), then clear your enrol stamp (here native_voting_enabled_at) so re-enrolling runs a full enrol path again with stored prefs.
  5. Latency: Tell users changes apply within about one lifecycle tick (Green Ambassador: ~1 hour).

Reference implementation in this repo: routes/voting_preferences.js, lib/nativeVotingService.js, jobs/voting_lifecycle.js. Deeper internal spec: architecture/VOTING_STRATEGY.md.

Start here: install the VeChain AI skill set

Before coding, install the VeChain AI skill pack so your AI assistant has current VeChain and VeBetterDAO context:

npx skills add vechain/vechain-ai-skills

Why this helps:

  • It reduces implementation drift when generating contract integration code.
  • It improves prompt quality for state machine, relayer, and XAllocationVoting flows.
  • It gives your team a shared vocabulary for contracts, lifecycle phases, and testing patterns.

Recommended workflow:

  1. Install the skill pack.
  2. Give your AI agent this page plus your app constraints.
  3. Ask the agent to scaffold files from the templates below.
  4. Fill in addresses, ABIs, and environment values using your own infra.

What you are building

High-level goal:

  • Keep user custody in their own wallet.
  • Convert eligible B3TR to VOT3.
  • Self-delegate VOT3 to activate voting power.
  • Persist voting preferences and opt-out in your DB; enrol wallet on chain and enable auto-voting when the user wants it.
  • Let relayers cast votes each round.
  • Keep the whole pipeline idempotent and recoverable.

Reference architecture

flowchart TD userWallet[ManagedWalletUser] --> stateEval[StateEngine_nextActionFor] stateEval -->|native_stake| buildStake[BuildStakeClauses] stateEval -->|native_enrol| buildEnrol[BuildEnrolClauses] stateEval -->|native_re_enrol| buildReenrol[BuildReenrolClauses] stateEval -->|native_sync_prefs| buildSync[BuildPrefsSyncClauses] stateEval -->|native_opt_out| buildOptOut[ToggleAutoVotingOff] buildStake --> simulateTx[SimulateTransaction] buildEnrol --> simulateTx buildReenrol --> simulateTx buildSync --> simulateTx buildOptOut --> simulateTx simulateTx -->|pass| signSend[DelegatedSignAndBroadcast] simulateTx -->|revert| skipAndLog[SkipAndLog] signSend --> receiptPoll[ReceiptPollAndReconcile] receiptPoll --> userState[UpdateVotingStateFields] userState --> relayerRound[RelayerCastsPerRound] relayerRound --> observeEvents[ObserveAndAuditEvents]

Core services:

  • State engine: pure decision function with deterministic transitions (includes prefs drift + opt-out precedence).
  • Lifecycle worker: periodic job that selects candidates and executes actions.
  • Preferences UI: writes users.voting_pref_* only; no direct chain call on Save.
  • Chain adapter: reads balances, personhood, preferences, and auto-voting flags.
  • Transaction executor: simulation, signing, fee delegation, broadcast, receipt polling.
  • Reconciler: resolves pending tx outcomes and updates persisted state.
  • Observability: emits structured logs and per-action counters.

State machine design

Use these six operational states:

StateMeaningTypical action
IneligibleUser cannot participate yet (not person / not managed)none
IdleEligible but no actionable balancenone
StakableHas B3TR to convert to VOT3 and has not self-delegatednative_stake
AwaitingSnapshotDelegated, but snapshot boundary not crossed yetnone
EnrollableDelegated + snapshot crossed + VOT3 availablenative_enrol (skipped when user opted out — stay idle until they clear opt-out)
EnrolledPlatform considers user enrolled and chain reports auto-voting onUsually none; may be native_sync_prefs (preference drift) or native_opt_out (user opted out while still on chain)
stateDiagram-v2 [*] --> Ineligible Ineligible --> Idle: eligible_managed_person Idle --> Stakable: B3TR_ready_not_delegated Stakable --> AwaitingSnapshot: native_stake AwaitingSnapshot --> Enrollable: snapshot_passed_has_VOT3 Enrollable --> Enrolled: native_enrol_or_native_re_enrol Enrolled --> Enrolled: native_sync_prefs

Re-enrol path:

  • If user was previously enrolled but chain state shows auto-voting disabled, execute native_re_enrol when eligibility conditions return — unless the user has voting_pref_opt_out set (then do not re-enable automatically).

Preference / opt-out path (while still auto-voting on chain):

  • native_sync_prefs: setUserVotingPreferences only; keeps auto-voting enabled, updates app split.
  • native_opt_out: toggleAutoVoting to disable on chain; clear local enrol stamps so a future opt-in is a clean enrol again.

Pure state transition function (template)

The real implementation in this codebase (lib/nativeVotingService.js) adds precedence rules: opt-out while auto-voting is on beats preference drift, both beat steady-state enrolled (none). Adapt this sketch to your schema names.

const OPERATIONAL_STATES = Object.freeze({
  Ineligible: "Ineligible",
  Idle: "Idle",
  Stakable: "Stakable",
  AwaitingSnapshot: "AwaitingSnapshot",
  Enrollable: "Enrollable",
  Enrolled: "Enrolled"
});

function toBigIntSafe(value, fallback = 0n) {
  try {
    if (value === null || value === undefined) return fallback;
    return BigInt(value);
  } catch {
    return fallback;
  }
}

/** True when DB desired app ids differ from voting_pref_synced_app_ids (your column names may vary). */
function votingPrefsDrift(user) {
  // Production: normalize bytes32 hex + ordering before compare — see Green Ambassador `votingPrefsDrift`.
  const desired = JSON.stringify(user.votingPrefAppIds ?? []);
  const synced = JSON.stringify(user.votingPrefSyncedAppIds ?? null);
  return desired !== synced;
}

function nextActionFor(user, ctx) {
  const isManagedWallet = user.walletType === "managed";
  const isPerson = ctx.isPerson === true;
  const b3tr = toBigIntSafe(ctx.b3trBalance);
  const vot3 = toBigIntSafe(ctx.vot3Balance);
  const currentRoundSnapshot = toBigIntSafe(ctx.currentRoundSnapshot);
  const delegatedAtBlock = toBigIntSafe(user.selfDelegatedAtBlock);
  const hasDelegated = Boolean(user.selfDelegatedAt);
  const autoVotingEnabled = ctx.autoVotingEnabled === true;
  const enrolledPlatform = Boolean(user.nativeVotingEnabledAt);
  const optedOut = user.votingPrefOptOut === true;

  if (!isManagedWallet || !isPerson) {
    return { state: OPERATIONAL_STATES.Ineligible, action: "none", reason: "not_eligible" };
  }

  // Already auto-voting on chain — maybe sync prefs or honour opt-out first.
  if (enrolledPlatform && autoVotingEnabled) {
    if (optedOut) {
      return { state: OPERATIONAL_STATES.Enrolled, action: "native_opt_out", reason: "user_opt_out_while_active" };
    }
    if (votingPrefsDrift(user)) {
      return { state: OPERATIONAL_STATES.Enrolled, action: "native_sync_prefs", reason: "prefs_drift" };
    }
    return { state: OPERATIONAL_STATES.Enrolled, action: "none", reason: "already_enrolled" };
  }

  // Platform thinks they enrolled once, but chain shows auto-voting off — re-enrol unless opted out.
  if (enrolledPlatform && !autoVotingEnabled) {
    if (optedOut) {
      return { state: OPERATIONAL_STATES.AwaitingSnapshot, action: "none", reason: "opted_out_skip_reenrol" };
    }
    if (vot3 >= 1n) {
      return { state: OPERATIONAL_STATES.Enrollable, action: "native_re_enrol", reason: "re_enable_needed" };
    }
    return { state: OPERATIONAL_STATES.AwaitingSnapshot, action: "none", reason: "await_reenrol_eligibility" };
  }

  if (!hasDelegated && b3tr >= 1n) {
    return { state: OPERATIONAL_STATES.Stakable, action: "native_stake", reason: "can_stake" };
  }

  if (hasDelegated && currentRoundSnapshot <= delegatedAtBlock) {
    return { state: OPERATIONAL_STATES.AwaitingSnapshot, action: "none", reason: "await_snapshot" };
  }

  if (hasDelegated && vot3 >= 1n) {
    if (optedOut) {
      return { state: OPERATIONAL_STATES.Idle, action: "none", reason: "opted_out_skip_enrol" };
    }
    return { state: OPERATIONAL_STATES.Enrollable, action: "native_enrol", reason: "can_enrol" };
  }

  return { state: OPERATIONAL_STATES.Idle, action: "none", reason: "nothing_to_do" };
}

Data model and persistence

Use durable fields (or equivalents) that let you resume safely:

-- Example only; adjust names for your schema
ALTER TABLE users ADD COLUMN wallet_type text;
ALTER TABLE users ADD COLUMN native_voting_enabled_at timestamptz;
ALTER TABLE users ADD COLUMN self_delegated_at timestamptz;
ALTER TABLE users ADD COLUMN self_delegated_at_block bigint;
ALTER TABLE users ADD COLUMN cached_is_person boolean;
ALTER TABLE users ADD COLUMN cached_auto_voting_enabled boolean;
ALTER TABLE users ADD COLUMN cached_b3tr_balance numeric(78,0);
ALTER TABLE users ADD COLUMN cached_vot3_balance numeric(78,0);

-- Voting preferences + opt-out (saved from UI; chain catches up via cron)
ALTER TABLE users ADD COLUMN voting_pref_opt_out boolean NOT NULL DEFAULT false;
ALTER TABLE users ADD COLUMN voting_pref_app_ids jsonb;           -- desired bytes32 ids for setUserVotingPreferences
ALTER TABLE users ADD COLUMN voting_pref_synced_app_ids jsonb;    -- last list successfully written on chain
ALTER TABLE users ADD COLUMN voting_pref_updated_at timestamptz;

-- Optional: admin JSON array of bytes32 hex strings users may pick besides your platform app
-- INSERT INTO settings (key, value, ...) VALUES ('voting_pref_allowed_app_ids', '[]', ...);

CREATE TABLE staking_transactions (
  id bigserial PRIMARY KEY,
  user_id bigint NOT NULL,
  type text NOT NULL,
  -- native_stake | native_enrol | native_re_enrol | native_sync_prefs | native_opt_out
  tx_hash text,
  status text NOT NULL,              -- pending, submitted, confirmed, failed
  error_message text,
  created_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

Persistence rules:

  • Write a transaction row before broadcast.
  • Update status on confirmation/revert/timeout.
  • Keep idempotent stamps on users after receipt confirmation (native_sync_prefs → update synced app ids to match desired; native_opt_out → clear enrol stamp so the next opt-in is explicit).
  • Reconcile timed-out submitted records in a separate job.

Transaction builders

Build clauses from interfaces, not hard-coded implementation details.

Stake clauses (approve -> convert -> self-delegate)

function buildStakeClauses({ b3tr, vot3, userAddress, amountWei }) {
  return [
    b3tr.approve(vot3.address, amountWei).clause,
    vot3.convertToVOT3(amountWei).clause,
    vot3.delegate(userAddress).clause
  ];
}

Enrol clauses (set preferences -> toggle auto-voting)

function buildEnrolClauses({ xav, preferredAppIds, userAddress }) {
  const appIds = Array.from(new Set(preferredAppIds)).slice(0, 15);
  if (appIds.length === 0) throw new Error("at_least_one_app_id_required");

  return [
    xav.setUserVotingPreferences(appIds).clause,
    xav.toggleAutoVoting(userAddress).clause
  ];
}

Re-enrol clause (toggle only)

Same clause shape as opt-off: turns auto-voting back on when the user had been disabled on chain but still holds VOT3.

function buildReenrolClauses({ xav, userAddress }) {
  return [xav.toggleAutoVoting(userAddress).clause];
}

Preference sync (already enrolled — update apps only)

function buildPrefsSyncClauses({ xav, preferredAppIds }) {
  const appIds = Array.from(new Set(preferredAppIds)).slice(0, 15);
  if (appIds.length === 0) throw new Error("at_least_one_app_id_required");

  return [xav.setUserVotingPreferences(appIds).clause];
}

Opt-out on chain (toggle while currently enabled)

Uses toggleAutoVoting so the contract disables auto-voting for that wallet (implementation-specific: VeBetterDAO may also clear on-chain preference storage when disabling).

function buildOptOutClauses({ xav, userAddress }) {
  return [xav.toggleAutoVoting(userAddress).clause];
}

Lifecycle scheduler and execution loop

Run an hourly job with lock + bounded concurrency.

Your selectCandidates SQL should remain permissive: include rows that might need stake, first enrol, re-enrol after chain disable, voting_pref_opt_out while cached_auto_voting_enabled is still true, and JSON drift between desired vs synced app ids — then let nextActionFor narrow to real work.

async function runLifecycleTick({ db, chain, signer, dryRun = false }) {
  const lockKey = 829473; // choose your own lock id
  const lock = await db.tryAdvisoryLock(lockKey);
  if (!lock.acquired) return { skipped: true, reason: "lock_held" };

  try {
    const candidates = await selectCandidates(db, { limit: 500 });
    const actionable = [];

    for (const user of candidates) {
      const ctx = await readCachedOrFreshContext(chain, user);
      const decision = nextActionFor(user, ctx);
      if (decision.action !== "none") actionable.push({ user, decision, ctx });
    }

    return await executeWithPool(actionable, 5, async ({ user, decision }) => {
      return executeForUser({ db, chain, signer, userId: user.id, action: decision.action, dryRun });
    });
  } finally {
    await db.releaseAdvisoryLock(lockKey);
  }
}

Per-user execution flow

async function executeForUser({ db, chain, signer, userId, action, dryRun }) {
  const user = await db.getManagedUserForUpdate(userId);
  const fresh = await chain.readVotingContext(user.walletAddress);
  const decision = nextActionFor(user, fresh);
  if (decision.action !== action) return { skipped: true, reason: "stale_decision" };

  // buildClausesForAction switches on native_stake | native_enrol | native_re_enrol |
  // native_sync_prefs | native_opt_out (prefs-only vs toggle-only).
  const clauses = buildClausesForAction({ action, fresh, user });
  const simulation = await chain.simulateClauses(clauses, user.walletAddress);
  if (!simulation.ok) return { skipped: true, reason: "simulation_revert", detail: simulation.error };
  if (dryRun) return { ok: true, dryRun: true, action };

  const fee = await getDynamicFeeFields(chain.thor);
  const gas = await estimateGasWithPadding(chain.thor, clauses, user.walletAddress, 1.5);
  const txRow = await db.insertPendingStakingTx(user.id, action);
  const tx = await signer.executeMultipleClausesTransaction(clauses, { ...fee, gas });
  await db.markSubmitted(txRow.id, tx.id);
  return await waitAndReconcileReceipt({ db, chain, txRowId: txRow.id, txHash: tx.id, action, user });
}

Dynamic fee and gas strategy

Do both:

  • calculate dynamic fee fields close to broadcast time
  • estimate gas with a safety multiplier (for example 1.5x)
async function getDynamicFeeFields(thor) {
  const block = await thor.blocks.getBestBlockCompressed();
  const baseFee = BigInt(block?.baseFeePerGas || "0");
  const maxPriorityFeePerGas = 1_000_000_000n; // 1 gwei example
  const maxFeePerGas = baseFee * 2n + maxPriorityFeePerGas;
  return {
    type: 1,
    maxPriorityFeePerGas: `0x${maxPriorityFeePerGas.toString(16)}`,
    maxFeePerGas: `0x${maxFeePerGas.toString(16)}`
  };
}

async function estimateGasWithPadding(thor, clauses, from, multiplier = 1.5) {
  const sim = await thor.transactions.simulateTransaction({ clauses, caller: from, gas: 0, gasPriceCoef: 0 });
  const used = BigInt(sim?.gasUsed || "21000");
  const padded = BigInt(Math.ceil(Number(used) * multiplier));
  return `0x${padded.toString(16)}`;
}

Relayer integration and verification

After users are enrolled:

  • relayers execute castVoteOnBehalfOf(voter, roundId) in each allocation round
  • relayers (or users after fallback window) execute reward claim flow
  • reward pool logic distributes relayer incentives

Verification checks you should automate:

  • user is auto-voting enabled for target round
  • AllocationAutoVoteCast (or equivalent) event appears for user and round
  • reward claim tx occurs and net reward arrives to user wallet

Minimal event verification template:

async function verifyAutoVoteCast({ thor, xavAddress, voter, roundId }) {
  const logs = await thor.logs.filter({
    criteriaSet: [
      {
        address: xavAddress,
        // Replace with your ABI event topic
        topic0: "0xEVENT_TOPIC_ALLOCATION_AUTO_VOTE_CAST"
      }
    ],
    order: "desc",
    range: { unit: "block", from: 0, to: "best" }
  });

  return logs.some((log) => {
    // decode with ABI decoder in real implementation
    return log.meta && log.data && matchesVoterAndRound(log, voter, roundId);
  });
}

Testing strategy

Use four layers.

1) Pure fixtures (no chain, no DB)

  • Cover all nextActionFor() branches.
  • Include boundary conditions (balance zero, snapshot equality, re-enrol cases).
  • Add cases for native_sync_prefs (desired ≠ synced), native_opt_out (opt-out flag while chain auto-voting still on), skip native_enrol when opted out pre-enrol, and no native_re_enrol when opted out after chain disable.

This repo: scripts/test_voting_lifecycle_state_machine.js.

2) Clause simulation against real contracts

  • Build stake / enrol / re-enrol / prefs-sync / opt-out toggle clauses from real user samples.
  • Run simulation only (no broadcast).

3) Dry-run lifecycle tick

  • Execute full selection + decision + simulation pipeline.
  • Assert per-action counts and no unhandled errors.

4) Canary rollout

  • Enable for a small internal wallet cohort first.
  • Observe at least one full round transition.
  • Confirm relayer-cast events, reward claims, and stable retries.

Example fixture test shape:

import assert from "node:assert/strict";

const cases = [
  {
    name: "eligible_without_delegate_becomes_stakable",
    user: { walletType: "managed", selfDelegatedAt: null, selfDelegatedAtBlock: null, nativeVotingEnabledAt: null },
    ctx: { isPerson: true, b3trBalance: "1000000000000000000", vot3Balance: "0", currentRoundSnapshot: "100", autoVotingEnabled: false },
    expectedAction: "native_stake"
  },
  {
    name: "enrolled_prefs_drift_triggers_sync",
    user: {
      walletType: "managed",
      nativeVotingEnabledAt: "2026-01-02T00:00:00.000Z",
      votingPrefOptOut: false,
      votingPrefAppIds: ["0xbb"],
      votingPrefSyncedAppIds: ["0xaa"]
    },
    ctx: { isPerson: true, b3trBalance: "0", vot3Balance: "1000000000000000000", currentRoundSnapshot: "120", autoVotingEnabled: true },
    expectedAction: "native_sync_prefs"
  },
  {
    name: "enrolled_opt_out_triggers_chain_disable",
    user: {
      walletType: "managed",
      nativeVotingEnabledAt: "2026-01-02T00:00:00.000Z",
      votingPrefOptOut: true,
      votingPrefAppIds: ["0xaa"],
      votingPrefSyncedAppIds: ["0xaa"]
    },
    ctx: { isPerson: true, b3trBalance: "0", vot3Balance: "1000000000000000000", currentRoundSnapshot: "120", autoVotingEnabled: true },
    expectedAction: "native_opt_out"
  }
];

for (const c of cases) {
  const decision = nextActionFor(c.user, c.ctx);
  assert.equal(decision.action, c.expectedAction, c.name);
}

References

Final note

Adapt this blueprint to your own platform requirements. Treat this page as implementation guidance, not a drop-in production config.