Technical Upgrade: stQEURO Multi-Vault with stQEUROFactory
Historical technical record. This document describes the multi-vault refactor as designed at the time. The
AaveVaultcontract referenced below has since been removed from the codebase — external yield now flows throughIExternalStakingVaultadapters (see Architecture.md); the factory/vaultId model described here is the one live in production.
Context
The protocol originally used a single stQEURO staking token for a single staking vault.
The architecture has been extended to support multiple vaults (identified by vaultId), with one dedicated token per vault:
vaultId = 1->stQEURO<VAULT_NAME_1>vaultId = 2->stQEURO<VAULT_NAME_2>- ...
These tokens are not fungible with each other (distinct ERC20 addresses, distinct metadata, and distinct yield accounting).
Upgrade objective
- Replace the "single stQEURO token" model with an upgradeable factory.
- Keep
stQEUROTokenas the vault-level staking token implementation. - Route yield (
YieldShift) throughvaultId. - Enforce strict vault registration through an on-chain self-call.
- Update deployment flows, interfaces, and tests.
Updated components
1) New contract: stQEUROFactory
This contract introduces an orchestration layer to deploy and register one stQEURO token per vault dynamically.
Responsibilities
- Deploy one
ERC1967Proxyper vault pointing to thestQEUROTokenimplementation. - Keep resolution indexes
vaultId <-> vault <-> token. - Store vault metadata (
vaultName). - Provide resolution getters for
YieldShift, scripts, and indexers.
Upgradeability and access control
- Pattern:
Initializable + AccessControlUpgradeable + SecureUpgradeable(UUPS viaSecureUpgradeable). - Roles:
GOVERNANCE_ROLE: factory reconfiguration (implementation, yieldShift, treasury, etc.)VAULT_FACTORY_ROLE: permission to register vaults
Main storage
stQEUROByVaultId[vaultId] -> tokenstQEUROByVault[vault] -> tokenvaultById[vaultId] -> vaultvaultIdByStQEURO[token] -> vaultId_vaultNamesById[vaultId] -> vaultName_vaultNameHashUsed[keccak256(vaultName)] -> bool
Strict registration: registerVault(uint256 vaultId, string vaultName)
Applied constraints:
onlyRole(VAULT_FACTORY_ROLE)- caller vault derived from
msg.sender(novaultparameter): strict self-register vaultId > 0- uniqueness:
vaultIdnot already usedvaultnot already registeredvaultNamenot already used
vaultNameformat:- length
1..12 - allowed characters:
A-Z,0-9
- length
Token creation:
- Name:
Staked Quantillon Euro {vaultName} - Symbol:
stQEURO{vaultName} - Deploy an
ERC1967Proxypointing to thestQEUROTokenimplementation - Initialize the proxy with dynamic metadata (
tokenName,tokenSymbol,vaultName)
Events
VaultRegistered(vaultId, vault, stQEUROToken, vaultName)FactoryConfigUpdated(key, oldValue, newValue)
Governance reconfiguration
updateYieldShift(address)updateTokenImplementation(address)updateOracle(address)updateTreasury(address)updateTokenAdmin(address)
2) stQEUROToken evolution
stQEUROToken remains the vault-level token (stake/unstake/yield logic preserved), but can now be initialized with dynamic metadata.
Key changes
- Added
string public vaultName. - Added an overloaded
initialize(...)supporting:_tokenName_tokenSymbol_vaultName
- Kept the legacy initializer (default metadata).
- Centralized initialization in
_initializeStQEURO(InitConfig memory cfg).
Roles
At initialization:
DEFAULT_ADMIN_ROLE,GOVERNANCE_ROLE,EMERGENCY_ROLE->adminYIELD_MANAGER_ROLE->adminYIELD_MANAGER_ROLE->yieldShift(explicit grant)
3) QuantillonVault evolution (self-registration)
Additions:
stQEUROFactory(linked factory address)stQEUROToken(token deployed for this vault)stQEUROVaultId(registered vault id)- event
StQEURORegistered(...)
New function:
selfRegisterStQEURO(address factory, uint256 vaultId, string vaultName)onlyRole(GOVERNANCE_ROLE)- local anti double-registration (
stQEUROToken == address(0)) - factory call originates from the vault itself, enforcing
msg.sender == vaulton the factory side - persists local references for factory/token/vaultId
4) YieldShift evolution (multi-vault routing)
Dependency change
- Old model: direct reference to a single
stQEURO. - New model: reference to
stQEUROFactory.
YieldDependencyConfig field replacement:
stQEURO->stQEUROFactory
New signature
addYield(uint256 vaultId, uint256 yieldAmount, bytes32 source)
Routing flow
- Verify global source authorization (
authorizedYieldSources+sourceToYieldType). - Pull USDC from source to
YieldShift. - Compute
userAllocation/hedgerAllocation. - Resolve target token through
stQEUROFactory.getStQEUROByVaultId(vaultId). - Revert if vault is not registered (
address(0)). - Keep coherent pull flow toward token:
safeIncreaseAllowance(stQEURO, userAllocation)IstQEURO(stQEURO).distributeYield(userAllocation)
Source policy:
- default remains global (no strict
source -> vaultIdbinding). - optional strict mode is available through:
setSourceVaultBinding(source, vaultId)clearSourceVaultBinding(source)setSourceVaultBindingEnforcement(enabled)
- when strict mode is enabled,
addYield(vaultId, ...)reverts unlesssourceToVaultId[msg.sender] == vaultId.
5) AaveVault evolution
Additions:
uint256 public yieldVaultId(governance config)setYieldVaultId(uint256)(reverts if0)updateYieldShift(address)(explicit rewiring)
Change in harvestAaveYield():
- validate
yieldVaultId != 0 - routing call:
yieldShift.addYield(yieldVaultId, netYield, bytes32("aave"))
6) Impacted interfaces
- New:
IStQEUROFactoryregisterVault(vaultId, vaultName)- resolution getters (
vaultId -> token,token -> vaultId, etc.)
IYieldShiftaddYieldbecomesaddYield(vaultId, yieldAmount, source)- dependency config:
stQEUROFactory - optional strict source routing:
setSourceVaultBinding(source, vaultId)clearSourceVaultBinding(source)setSourceVaultBindingEnforcement(bool)
IAaveVaultsetYieldVaultId(uint256)updateYieldShift(address)yieldVaultId()
IQuantillonVaultselfRegisterStQEURO(...)- getters
stQEUROFactory,stQEUROToken,stQEUROVaultId
IstQEURO- overloaded
initialize(...)with dynamic metadata
- overloaded
7) Deployment scripts and wiring
DeployQuantillon.s.sol is updated to:
- Deploy
YieldShift. - Deploy
stQEUROTokenimplementation. - Deploy
stQEUROFactory(proxy) while injecting token implementation. - Configure
YieldShiftwithstQEUROFactory. - Rewire
AaveVaulttoYieldShift. - Leave
AaveVault.yieldVaultIdunset until governance/core-team vault decision. - Do not register any stQEURO vault token during bootstrap deployment.
- Register vaults later via explicit core-team governance flow.
At bootstrap, no vault-name environment variable is consumed by DeployQuantillon.s.sol.
Exports:
- addresses and ABIs now include
stQEUROFactory.
8) Breaking changes
Breaking changes assumed for this iteration:
YieldShift.addYield(...)now requiresvaultId.YieldShiftdependency points to the factory instead of a single token.- Deployment flows must explicitly register vaults.
- Integrations calling the old API
addYield(yieldAmount, source)must be updated.
No on-chain legacy migration is included in this pass.
9) Validation and test coverage
New test
test/stQEUROFactory.t.sol- registration OK
- multi-vault registration (2 active vaults)
- duplicate
vaultId - duplicate vault address
- invalid/duplicate
vaultName - unauthorized caller
Updated tests
YieldShift.t.sol(vaultIdrouting, factory dependency, strictsource -> vaultIdpolicy, multi-vault routing assertions)AaveVault.t.sol(yieldVaultId, newaddYieldsignature)DeploymentSmoke.t.sol,IntegrationTests.t.sol,QuantillonInvariants.t.sol,LiquidationScenarios.t.sol,stQEUROToken.t.sol(initializer/signature alignment)
Result
forge build --skip testpasses- targeted suites pass
forge test -qpasses
10) Runbook: adding a new staking vault
To onboard vaultId = N:
- Deploy/configure the new vault (including governance roles).
- From governance, grant
VAULT_FACTORY_ROLEto the vault address. - Call
vault.selfRegisterStQEURO(factory, N, VAULT_NAME). - Verify:
factory.getStQEUROByVaultId(N) != 0factory.getVaultById(N) == vaultfactory.getVaultName(N) == VAULT_NAME
- If the vault receives yield through
YieldShift, routeaddYield(N, ...)calls from the relevant source. - Update address/ABI exports and the off-chain integration layer.
11) Attention points for next steps
Rotation runbook: updateTokenImplementation
Use this for future vault deployments only (already-deployed vault tokens are unchanged).
- Deploy the new
stQEUROTokenimplementation contract. - Smoke-test initialization compatibility off-chain:
- verify the 9-argument initializer used by the factory still succeeds.
- verify role wiring still grants
YIELD_MANAGER_ROLEtoYieldShift.
- From governance, execute:
stQEUROFactory.updateTokenImplementation(newImplementation).
- Register a canary vault and verify:
- metadata (
name,symbol,vaultName) is correct. factory.getStQEUROByVaultId(canaryId)resolves as expected.- yield routing still works through
YieldShift.addYield(canaryId, ...).
- metadata (
- If canary checks fail, roll back by re-pointing:
stQEUROFactory.updateTokenImplementation(previousImplementation).
Monitoring runbook: VaultRegistered and indexer consistency
Use scripts/deployment/monitor-stqeuro-factory.sh:
./scripts/deployment/monitor-stqeuro-factory.sh \
--rpc-url <RPC_URL> \
--factory <STQEURO_FACTORY_ADDRESS> \
--from-block <START_BLOCK>
What it validates:
- Streams/parses
VaultRegisteredlogs in the selected block range. - For each event, checks on-chain registry consistency:
getStQEUROByVaultId(vaultId) == event.stQEUROTokengetVaultById(vaultId) == event.vaultgetVaultIdByStQEURO(event.stQEUROToken) == vaultId
- Optionally compares against an indexer snapshot:
--indexer-snapshot <path-to-json>
Status of prior follow-ups
- Multi-vault integration tests: implemented.
- Optional strict
source -> vaultIdpolicy: implemented. updateTokenImplementationrotation procedure: documented.VaultRegistered+ indexer consistency monitoring: implemented.
12) Executive summary
This upgrade introduces a robust factory layer that enables QEURO staking to scale across multiple vaults without mixing staking positions.
- One vault = one dedicated stQEURO token.
- Yield routing becomes explicitly keyed by
vaultId. - Self-registration guarantees strict and auditable registration semantics.
- Deployment/wiring is automated for the first instance (
vaultId = 1) and ready for the next vaults.