Skip to content

feat: add rVirtual conversion via RVirtualConverter - #181

Open
koo-virtuals wants to merge 16 commits into
mainfrom
feat/vp-2434
Open

feat: add rVirtual conversion via RVirtualConverter#181
koo-virtuals wants to merge 16 commits into
mainfrom
feat/vp-2434

Conversation

@koo-virtuals

@koo-virtuals koo-virtuals commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Adds an open, permissionless 1:1 VIRTUAL -> rVirtual converter (RVirtualConverter, UUPS upgradeable), pre-funded with the rVirtual supply. veVirtual gains convertVeVirtualToRVirtual(id), which deletes a staking position (regardless of maturity or autoRenew state) and routes its underlying VIRTUAL through the same open converter entrypoint any wallet can call directly - no backend distribution step required.


Note

High Risk
New token movement and veVirtual lock deletion depend on an admin-wired, upgradeable external converter; mitigations are on-chain delivery checks and allowance clearing, but misconfiguration or compromised admin keys remain high-impact.

Overview
Introduces a permissionless 1:1 VIRTUAL → rVirtual path via a new UUPS RVirtualConverter: callers swap VIRTUAL (sent straight to a fixed treasury, not held in the converter) for pre-funded rVirtual, with balance-delta checks so fee-on-transfer payout tokens cannot silently under-deliver. Initialization enforces matching token decimals and distinct input/output addresses; admin sweep paths are omitted by design.

veVirtual gains admin setRVirtualConverter (base-token match + snapshotted rVirtualToken) and user convertVeVirtualToRVirtual, which removes a lock (any maturity / auto-renew state), forwards VIRTUAL through the same open converter entrypoint, verifies full rVirtual delivery against the snapshot (reverting the whole tx—including lock removal—on shortfall), uses forceApprove and clears allowance after the call, and burns voting power. _disableInitializers constructors are added on upgradeable implementations.

Hardhat coverage adds converter + integration tests and audit-focused mocks (malicious / no-op / non-consuming converters, fee-on-transfer token, UUPS round-trip).

Reviewed by Cursor Bugbot for commit d7cb747. Bugbot is set up for automated code reviews on this repo. Configure here.

Adds an open, permissionless 1:1 VIRTUAL -> rVirtual converter
(RVirtualConverter, UUPS upgradeable), pre-funded with the rVirtual
supply. veVirtual gains convertVeVirtualToRVirtual(id), which deletes
a staking position (regardless of maturity or autoRenew state) and
routes its underlying VIRTUAL through the same open converter
entrypoint any wallet can call directly - no backend distribution
step required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b98ca31. Configure here.

Comment thread contracts/token/RVirtualConverter.sol
koo-virtuals and others added 15 commits July 30, 2026 10:42
Compute the delivered amount via a balanceOf delta around the transfer
and require it equals the requested amount, so a taxed/fee-on-transfer
rVirtual token causes the whole conversion to revert atomically instead
of silently under-delivering while the caller is charged in full. The
emitted event also now reports the actually-delivered amount rather
than the requested one.

Addresses AUDIT_REPORT.md finding M-01.
No code check added by design - the protocol guarantees both VIRTUAL
and rVirtual use 18 decimals, so a runtime decimals() equivalence
assertion isn't needed. This comment records that assumption at the
exact line the raw 1:1 conversion depends on it, so a future
migration to a differently-decimaled token isn't wired in silently.

Addresses AUDIT_REPORT.md finding M-02 (comment-only fix per request;
no behavior change, no new test).
Add a `treasury` address, required non-zero and set once at
initialize(), and change convertVirtualToRVirtual() to send the
caller's incoming VIRTUAL straight to it instead of holding it in the
converter contract. VIRTUAL is no longer custodied by
RVirtualConverter at any point, so there is no accumulated balance for
a compromised/malicious ADMIN_ROLE key to sweep via a repointed
converter or a malicious UUPS upgrade.

This does not change the original L-02 attack surface (a malicious
`rVirtualConverter` repoint on veVirtual, or a malicious UUPS upgrade,
can still intercept a user's approved VIRTUAL before it reaches
treasury) - that remains an acknowledged, trust-gated risk. It does
remove the "accumulated VIRTUAL balance" component of the blast radius
entirely, since funds never sit in this contract.

Addresses AUDIT_REPORT.md finding L-02 (additional hardening beyond
the original recommendation, per request).
convertVeVirtualToRVirtual() now grants the converter's allowance via
forceApprove(), consistent with the rest of the file's SafeERC20
convention (already imported/used elsewhere via `using SafeERC20 for
IERC20`), and checked/return-value-safe by construction. forceApprove
zeroes the allowance first if it's currently non-zero, then sets the
new value - this is a strict superset of what raw approve() does for
well-behaved tokens like VIRTUAL, so there is no behavior change on
the happy path; it additionally protects against ERC20 tokens that
revert on a direct non-zero-to-non-zero approve() (e.g. USDT-style
tokens), should baseToken ever be swapped for one of those.

Addresses AUDIT_REPORT.md finding L-04.
RVirtualConverter.initialize() now rejects virtualToken and
rVirtualToken being the same address. IRVirtualConverter exposes a
virtualToken() getter, and veVirtual.setRVirtualConverter() now
asserts the converter's virtualToken() matches veVirtual's own
baseToken before wiring it in - closing the gap where a
misconfigured/mismatched converter would previously only fail with a
confusing revert deep inside a user's convertVeVirtualToRVirtual()
call instead of at configuration time.

NoOpConverterMock gains a constructor-supplied virtualToken to satisfy
the extended interface.

Addresses AUDIT_REPORT.md finding L-09.
FeeOnTransferMock, MaliciousConverterMock, and MockERC20SixDecimals
were created during the earlier security audit's PoC work but never
added to git. FeeOnTransferMock is a dependency of the M-01 fix test
added in an earlier commit; tracking all three now so the test suite
is reproducible from a clean checkout.
RVirtualConverter's AdminWalletUpdated event now emits both the
previous and new adminWallet, both indexed, so an off-chain monitor
can filter directly on this state change and reconstruct history
without replaying the full event log. veVirtual's own admin events
(RVirtualConverterUpdated, and the absence of an event on
setMaxWeeks) are intentionally left unchanged per request.

Addresses AUDIT_REPORT.md finding L-10 (RVirtualConverter side only).
…cks/

Test-only UUPS upgrade-path mock was sitting alongside production
contracts in contracts/token/. Relocated with the other test-only
mocks for this feature; no behavior change.

Addresses AUDIT_REPORT.md finding I-05.
Once incoming VIRTUAL is routed directly to treasury (L-02), this
contract never custodies VIRTUAL, so the adminWallet sweep mechanism
(setAdminWallet, withdrawVirtual, and their events) has nothing left
to do. Removed entirely rather than left as dead code with no
consumer - narrows the admin-controlled surface down to just the
UUPS upgrade path.

Addresses AUDIT_REPORT.md finding I-06.
Add constructor() { _disableInitializers(); } to both
RVirtualConverter and veVirtual, matching the standard OZ upgradeable
hardening pattern already used by every other upgradeable contract in
this repo (AgentVeToken, Bonding, and 35+ others). Without this, the
implementation contract itself (as opposed to any proxy pointing to
it) could be initialized directly by an attacker, granting them
DEFAULT_ADMIN_ROLE/ADMIN_ROLE over the naked implementation.

Addresses AUDIT_REPORT.md finding L-12.
…tate

Simulates the real-world scenario: an already-live veVirtual proxy
with existing stakes/votes gets upgraded to a new implementation that
adds constructor() { _disableInitializers(); }. Forces a genuinely
new implementation deployment (redeployImplementation: "always") and
asserts every pre-upgrade storage value (lock data, decayed balance,
raw voting power) survives untouched, and that the new implementation
is independently hardened against direct initialize() calls.

This is empirical proof (not just theory) that adding this
constructor to a contract that has already been deployed and upgraded
multiple times on mainnet is safe: constructors only ever run once,
at the new implementation's own deployment transaction, and never
execute in the context of - or touch the storage of - the proxy.
…initialize

RVirtualConverter's 1:1 conversion is a raw-integer transfer with no decimals
rescaling - correct only when both tokens use the same decimals. The
invariant previously lived only in a comment; a future deployment against a
mismatched pair (e.g. 6-decimal vs 18-decimal) would have mis-scaled every
conversion silently. Now asserted via IERC20Metadata.decimals() at
initialize().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… call

convertVeVirtualToRVirtual() relied on the converter itself consuming its
forceApprove'd allowance via transferFrom. A converter that doesn't pull the
funds (whether by bug or a no-op/malicious replacement) left the full
permission standing indefinitely - including after a later repoint to a
different converter, since forceApprove only ever writes to the *current*
converter address. veVirtual now clears the allowance itself right after the
call, regardless of what the converter did with it.

Updated the existing no-op-converter test (previously asserted the residual
allowance as documented, expected behavior) to instead assert it's cleared,
and added a dedicated test for the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
convertVeVirtualToRVirtual() deleted the caller's lock, called the converter,
and returned - never confirming the caller was actually paid. That guarantee
rested entirely on a check inside RVirtualConverter, a separate contract that
is upgradeable behind the same ADMIN_ROLE that can also repoint
rVirtualConverter (H-1). A staker forfeiting a position after a deliberate
or mistaken repoint/upgrade could lose the entire locked position and
receive nothing, with the transaction still reporting success.

veVirtual now confirms delivery itself:
- setRVirtualConverter() snapshots the converter's declared rVirtualToken()
  into a new `rVirtualToken` storage slot at the moment the converter is
  wired in (same trust boundary as the existing virtualToken() check) - not
  re-read from the converter at conversion time, so a later in-place upgrade
  of the same converter can't simply claim a different payout token to dodge
  the check.
- convertVeVirtualToRVirtual() snapshots the account's rVirtualToken balance
  before the external call and requires the exact expected amount was
  delivered after it. A shortfall reverts the whole transaction, including
  the lock deletion, so the account keeps its position instead of losing it
  silently.

This substantially reduces H-1's impact: a redirected/upgraded converter can
now only cause conversions to revert (denial of service on this one
feature), not fund loss - so H-1 does not additionally need a timelock to be
considered adequately mitigated for now.

Added `rVirtualToken()` to IRVirtualConverter (RVirtualConverter.sol already
exposes it as a public state variable, satisfying the interface with no
contract change needed). Updated NoOpConverterMock/MaliciousConverterMock
constructors to supply it, and added NonConsumingConverterMock plus
dedicated regression tests: two prove a non-delivering converter (no-op and
fund-stealing) now reverts and preserves the lock, one proves normal
conversions against the real converter are unaffected. The existing L-04
allowance tests were rewritten against NonConsumingConverterMock, since a
non-delivering converter no longer reaches a successful call at all (the
whole point of this fix) and can therefore no longer exercise the
allowance-clearing path - only a converter that delivers correctly through a
path that skips consuming the allowance still can.

No deploy script changes needed: initialize() signatures are unchanged, and
rVirtualToken is only ever set via setRVirtualConverter(), same as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant