Skip to content

feat: add rVirtual conversion via RVirtualConverter - #181

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

feat: add rVirtual conversion via RVirtualConverter#181
koo-virtuals wants to merge 17 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
Changes veVirtual staking exit semantics and token routing with admin-configurable external converter wiring; bugs or malicious repoints could cause fund loss, though the PR adds explicit delivery and payout-token pinning mitigations.

Overview
Introduces a permissionless 1:1 VIRTUAL → rVirtual path via a new UUPS RVirtualConverter (pre-funded rVirtual, VIRTUAL sent straight to treasury, delivery balance-delta checks, matching decimals at init). veVirtual gains admin setRVirtualConverter (input token match + pinned payout token on repoint) and user convertVeVirtualToRVirtual, which removes a lock (any maturity/autoRenew), approves the converter, and requires full rVirtual delivery or reverts the whole tx; allowances are cleared with forceApprove. veVirtual also adds _disableInitializers on the implementation.

Adds IRVirtualConverter, test mocks, and Hardhat coverage for converter behavior, UUPS upgrades, and audit-style failure modes. .gitignore now ignores lib/ and docs/.

Reviewed by Cursor Bugbot for commit 34b95a8. 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 16 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>
…snapshot it

Reviewer follow-up on d7cb747: snapshotting rVirtualToken at wiring time closes the
case where the SAME converter is later upgraded to lie about its payout token, but
not the case where ADMIN_ROLE repoints to a DIFFERENT, brand-new converter.
setRVirtualConverter re-read rVirtualToken() from whichever converter was being
wired in with no comparison against the previously stored value - so a repoint
could nominate a worthless token as its own rVirtualToken(), pass the virtualToken()
check (which only validates the INPUT side), and have the M-02 delivery check in
convertVeVirtualToRVirtual() validate against that nomination instead of against
real value. The check would confirm something arrived, not that it had value - a
staker's position could still be destroyed for nothing.

setRVirtualConverter now pins rVirtualToken on first wiring and requires every
subsequent repoint to declare the exact same token, reverting with "Payout token
mismatch" otherwise. A repoint to a converter paying a different token can still
block conversions (the availability risk already covered by Finding 1's
downgraded severity), but can no longer redirect stakers into a different asset.

Added 3 regression tests: repointing to a converter nominating a different payout
token now reverts; the first-ever wiring can still pin any token (no prior value to
conflict with); repointing to a different converter that nominates the SAME
already-pinned token still succeeds.

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