Step 7: Return Tokens
The off-ramp is the unwind path: exit strategy positions, return the investor assets, and (optionally) revoke the standing permission.
There is one important constraint to internalize: unregisterVault() is not implemented. The interface declares it, but the live implementation reverts with NotImplemented(). The off-ramp is purely at the protocol layer (vault → investor) plus the standing-permission layer (invalidateOperatorPermission). The DS registry binding survives.
Typical unwind sequence
1. Exit downstream protocol positions
2. Move DST from the vault back to the investor wallet
4. (Optional) Investor calls invalidateOperatorPermission(operator)
to invalidate any future use of the standing permission
Each step is independent. You can return funds without revoking; you can revoke without returning funds. They serve different purposes: returning funds completes the current on-ramp, revoking blocks future on-ramps.
Returning DST to the investor
This is a vault-level operation. The minimal vault from step 3 has a single withdraw function:
function withdraw(address token, address to, uint256 amount) external onlyOperator {
IERC20(token).transfer(to, amount);
}
So the operator (factory) calls:
await operatorWalletClient.writeContract({
address: factoryAddress,
abi: factoryAbi,
functionName: "withdraw",
args: [investorAddress, parseUnits("100", 6)],
});
Inside withdraw():
function withdraw(uint256 amount) external {
address vault = investorVaults[msg.sender];
require(vault != address(0), "NO_VAULT");
MinimalVault(vault).withdraw(dsToken, msg.sender, amount);
emit Withdraw(msg.sender, vault, amount);
}
Note: the transfer from vault to investor wallet succeeds because both addresses are registered to the same investor in the DS registry. That's exactly the post-condition the on-ramp put in place.
Revoking the standing permission
The investor (not the operator) calls invalidateOperatorPermission(operator) directly. This is the only function that increments operatorNonce(investor, operator).
await investorWalletClient.writeContract({
address: VAULT_REGISTRAR,
abi: vaultRegistrarAbi,
functionName: "invalidateOperatorPermission",
args: [operatorAddress],
});
Effects:
operatorNonce(investor, operator)increments by 1.- All previously issued signatures from this investor to this operator become unusable for future
registerVault()calls (they encode the old nonce). - Already-registered vaults are not unbound. They keep their registry entry. Funds in them are unaffected.
- Other investors' signatures to the same operator are unaffected.
- Other operators' signatures from this investor are unaffected.
OperatorPermissionInvalidated(investor, operator, newNonce)is emitted.
The frontend /vault-registrar "Revoke" tab calls this directly via the connected investor wallet. There is no gateway path for this — it must come from the investor.
Why unregisterVault() is unavailable
The interface declares it, but VaultRegistrar.unregisterVault(...) always reverts with NotImplemented(). There is no admin-only escape hatch and no operator-only escape hatch. This is intentional for now: removing a registry binding is a compliance-sensitive operation that the contract does not yet model.
If you need the registry to forget a vault, escalate via Securitize. Your off-ramp design must not depend on it.
Lifecycle recap
- Faucet drip funded and auto-registered the investor.
- Registrar admin granted your factory
OPERATOR_ROLE. - Your factory deployed (or pre-derived) an investor vault.
- The investor signed the EIP-712 standing permission.
- Your factory called
registerVault()and emittedVaultRegistered. - DST moved into the vault; your strategy did its thing.
- You returned DST to the investor and (optionally) revoked future operator permission via
invalidateOperatorPermission().
Operator checklist
- Investor wallet DST balance is back to its expected value.
- Vault DST balance is zero (or whatever your protocol's terminal state is).
- You captured
Withdrawevents for audit. - If revoked:
OperatorPermissionInvalidatedevent captured andoperatorNonce(investor, operator)reads as the new value. - You did not rely on
unregisterVault()anywhere in your off-ramp logic.