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.
  • Let users change their preferred dapps for auto-voting preferences.

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.
  • Enrol wallet preferences and enable auto-voting.
  • 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]
  buildStake --> simulateTx[SimulateTransaction]
  buildEnrol --> simulateTx
  buildReenrol --> 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.
  • Lifecycle worker: periodic job that selects candidates and executes actions.
  • 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:

StateMeaningAction
IneligibleUser cannot participate yet (no 3 actions in last 12 weeks)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
EnrolledAuto-voting enablednone

Re-enrol path:

  • If user was previously enrolled but chain state shows auto-voting disabled, execute native_re_enrol when eligibility conditions return.

Pure state transition function (template)

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;
  }
}

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 hadEnabledBefore = Boolean(user.nativeVotingEnabledAt);

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

  if (autoVotingEnabled) {
    return { state: OPERATIONAL_STATES.Enrolled, action: "none", reason: "already_enrolled" };
  }

  if (hadEnabledBefore && !autoVotingEnabled && vot3 >= 1n) {
    return { state: OPERATIONAL_STATES.Enrollable, action: "native_re_enrol", reason: "re_enable_needed" };
  }

  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) {
    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);

CREATE TABLE staking_transactions (
  id bigserial PRIMARY KEY,
  user_id bigint NOT NULL,
  action_type text NOT NULL,         -- native_stake, native_enrol, native_re_enrol
  tx_hash text,
  status text NOT NULL,              -- pending, submitted, confirmed, failed
  error_code text,
  error_message text,
  created_at timestamptz NOT NULL DEFAULT now(),
  updated_at timestamptz NOT NULL DEFAULT now()
);

Persistence rules:

  • Write a transaction row before broadcast.
  • Update status on confirmation/revert/timeout.
  • Keep idempotent stamps on users after receipt confirmation.
  • 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)

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

Lifecycle scheduler and execution loop

Run an hourly job with lock + bounded concurrency.

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" };

  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).

2) Clause simulation against real contracts

  • Build stake/enrol/re-enrol 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: "delegated_and_snapshot_crossed_becomes_enrollable",
    user: { walletType: "managed", selfDelegatedAt: "2026-01-01T00:00:00.000Z", selfDelegatedAtBlock: "100", nativeVotingEnabledAt: null },
    ctx: { isPerson: true, b3trBalance: "0", vot3Balance: "1000000000000000000", currentRoundSnapshot: "120", autoVotingEnabled: false },
    expectedAction: "native_enrol"
  }
];

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.