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:
- Install the skill pack.
- Give your AI agent this page plus your app constraints.
- Ask the agent to scaffold files from the templates below.
- 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:
| State | Meaning | Action |
|---|---|---|
Ineligible | User cannot participate yet (no 3 actions in last 12 weeks) | none |
Idle | Eligible but no actionable balance | none |
Stakable | Has B3TR to convert to VOT3 and has not self-delegated | native_stake |
AwaitingSnapshot | Delegated, but snapshot boundary not crossed yet | none |
Enrollable | Delegated + snapshot crossed + VOT3 available | native_enrol |
Enrolled | Auto-voting enabled | none |
Re-enrol path:
- If user was previously enrolled but chain state shows auto-voting disabled, execute
native_re_enrolwhen 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
usersafter receipt confirmation. - Reconcile timed-out
submittedrecords 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
- VeBetterDAO Automation Docs
- Relayers Overview
- VeBetterDAO Contracts Repository
@vechain/vebetterdao-contractson npm- VeChain SDK Documentation
- veDelegate Docs
Final note
Adapt this blueprint to your own platform requirements. Treat this page as implementation guidance, not a drop-in production config.