3.3 On-Chain Vault Contract Implementation
The public smart contract vault must implement a minimal execution interface to limit attack vectors and focus on verification and dispatch.
Interface Definition
solidity
interface IRelayedVault {
/// @notice Execute a Canton-signed intent
/// @param intentPayload ABI-encoded intent data
/// @param cantonSig Canton signature over intentPayload hash
/// @param proof Additional proof data (pattern-specific)
function executeIntent(
bytes calldata intentPayload,
bytes calldata cantonSig,
bytes calldata proof
) external;
/// @notice Get current nonce for replay protection
function getNonce() external view returns (uint256);
/// @notice Emergency pause mechanism
function pause() external;
/// @notice Resume operations
function unpause() external;
}Core Implementation
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/security/Pausable.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
contract CantonDeFiVault is
IRelayedVault,
Pausable,
ReentrancyGuard,
AccessControl
{
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE");
// Canton authorization
address public cantonSigner;
bytes32 public authorizedPartyId;
// Replay protection
uint256 public nonce;
// Protocol adapters
mapping(bytes32 => address) public adapters;
// Events
event IntentExecuted(
uint256 indexed nonce,
bytes32 indexed intentHash,
address[] protocols,
bool success
);
event AdapterRegistered(
bytes32 indexed protocolId,
address adapter
);
struct Intent {
string vaultId;
uint256 nonce;
Action[] actions;
uint256 deadline;
}
struct Action {
bytes32 protocolId;
bytes4 selector;
bytes params;
}
constructor(
address _cantonSigner,
bytes32 _authorizedPartyId
) {
cantonSigner = _cantonSigner;
authorizedPartyId = _authorizedPartyId;
_setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
_setupRole(ADMIN_ROLE, msg.sender);
_setupRole(EMERGENCY_ROLE, msg.sender);
}
/// @inheritdoc IRelayedVault
function executeIntent(
bytes calldata intentPayload,
bytes calldata cantonSig,
bytes calldata proof
)
external
override
nonReentrant
whenNotPaused
{
// 1. Verify Canton signature
_verifyCantonSignature(intentPayload, cantonSig, proof);
// 2. Parse intent
Intent memory intent = abi.decode(intentPayload, (Intent));
// 3. Validate intent
_validateIntent(intent);
// 4. Update nonce
nonce = intent.nonce;
// 5. Execute actions
address[] memory protocols = _executeActions(intent.actions);
// 6. Emit event
emit IntentExecuted(
intent.nonce,
keccak256(intentPayload),
protocols,
true
);
}
/// @notice Verify Canton signature on intent
function _verifyCantonSignature(
bytes calldata intentPayload,
bytes calldata cantonSig,
bytes calldata /* proof */
) internal view {
bytes32 intentHash = keccak256(intentPayload);
bytes32 ethSignedHash = keccak256(
abi.encodePacked("\x19Ethereum Signed Message:\n32", intentHash)
);
address recoveredSigner = _recoverSigner(ethSignedHash, cantonSig);
require(
recoveredSigner == cantonSigner,
"Invalid Canton signature"
);
}
/// @notice Validate intent structure and timing
function _validateIntent(Intent memory intent) internal view {
// Nonce check
require(
intent.nonce > nonce,
"Invalid nonce"
);
// Deadline check
require(
block.timestamp <= intent.deadline,
"Intent expired"
);
// Actions non-empty
require(
intent.actions.length > 0,
"No actions"
);
}
/// @notice Execute all actions in the intent
function _executeActions(Action[] memory actions)
internal
returns (address[] memory)
{
address[] memory protocols = new address[](actions.length);
for (uint256 i = 0; i < actions.length; i++) {
Action memory action = actions[i];
// Get adapter for protocol
address adapter = adapters[action.protocolId];
require(adapter != address(0), "Unknown protocol");
// Execute via adapter
(bool success, bytes memory result) = adapter.call(
abi.encodePacked(action.selector, action.params)
);
require(success, string(result));
protocols[i] = adapter;
}
return protocols;
}
/// @notice Recover signer from signature
function _recoverSigner(bytes32 hash, bytes memory signature)
internal
pure
returns (address)
{
require(signature.length == 65, "Invalid signature length");
bytes32 r;
bytes32 s;
uint8 v;
assembly {
r := mload(add(signature, 32))
s := mload(add(signature, 64))
v := byte(0, mload(add(signature, 96)))
}
if (v < 27) {
v += 27;
}
require(v == 27 || v == 28, "Invalid signature v");
return ecrecover(hash, v, r, s);
}
/// @inheritdoc IRelayedVault
function getNonce() external view override returns (uint256) {
return nonce;
}
/// @notice Register protocol adapter
function registerAdapter(
bytes32 protocolId,
address adapter
) external onlyRole(ADMIN_ROLE) {
require(adapter != address(0), "Invalid adapter");
adapters[protocolId] = adapter;
emit AdapterRegistered(protocolId, adapter);
}
/// @notice Update Canton signer
function updateCantonSigner(address newSigner)
external
onlyRole(ADMIN_ROLE)
{
require(newSigner != address(0), "Invalid signer");
cantonSigner = newSigner;
}
/// @inheritdoc IRelayedVault
function pause() external override onlyRole(EMERGENCY_ROLE) {
_pause();
}
/// @inheritdoc IRelayedVault
function unpause() external override onlyRole(EMERGENCY_ROLE) {
_unpause();
}
/// @notice Emergency withdraw
function emergencyWithdraw(
address token,
uint256 amount,
address recipient
) external onlyRole(EMERGENCY_ROLE) whenPaused {
if (token == address(0)) {
payable(recipient).transfer(amount);
} else {
IERC20(token).transfer(recipient, amount);
}
}
}Security Features
1. Intent Validation
The contract rigorously validates all intents:
solidity
function _validateIntent(Intent memory intent) internal view {
// ✓ Nonce must be strictly increasing
require(intent.nonce > nonce, "Invalid nonce");
// ✓ Intent must not be expired
require(block.timestamp <= intent.deadline, "Intent expired");
// ✓ Actions must be non-empty
require(intent.actions.length > 0, "No actions");
// ✓ Vault ID must match (if tracked)
require(
keccak256(bytes(intent.vaultId)) == keccak256(bytes(vaultId)),
"Vault mismatch"
);
}2. Replay Protection
Multiple layers of replay protection:
Nonce Enforcement:
solidity
// Strict ordering: must be greater than current nonce
require(intent.nonce > nonce, "Invalid nonce");
// Update immediately after validation
nonce = intent.nonce;Deadline Enforcement:
solidity
// Intent cannot be executed after deadline
require(
block.timestamp <= intent.deadline,
"Intent expired"
);Intent Hash Tracking (Optional):
solidity
mapping(bytes32 => bool) public executedIntents;
function _checkReplay(bytes32 intentHash) internal {
require(!executedIntents[intentHash], "Already executed");
executedIntents[intentHash] = true;
}3. Access Control
Role-based access for administrative functions:
solidity
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant EMERGENCY_ROLE = keccak256("EMERGENCY_ROLE");
// Only admins can register adapters
function registerAdapter(bytes32 protocolId, address adapter)
external
onlyRole(ADMIN_ROLE)
{
adapters[protocolId] = adapter;
}
// Emergency actions require EMERGENCY_ROLE
function pause() external onlyRole(EMERGENCY_ROLE) {
_pause();
}Protocol Adapters
Modular adapters isolate protocol-specific logic:
Base Adapter Interface
solidity
interface IProtocolAdapter {
function execute(bytes calldata params)
external
returns (bytes memory result);
function protocol() external view returns (string memory);
}Example: Uniswap V3 Adapter
solidity
contract UniswapV3Adapter is IProtocolAdapter {
ISwapRouter public immutable swapRouter;
address public immutable vault;
constructor(address _swapRouter, address _vault) {
swapRouter = ISwapRouter(_swapRouter);
vault = _vault;
}
modifier onlyVault() {
require(msg.sender == vault, "Not vault");
_;
}
function swap(
address tokenIn,
address tokenOut,
uint256 amountIn,
uint256 minAmountOut,
uint24 fee
) external onlyVault returns (uint256 amountOut) {
// Approve router
IERC20(tokenIn).approve(address(swapRouter), amountIn);
// Prepare swap params
ISwapRouter.ExactInputSingleParams memory params =
ISwapRouter.ExactInputSingleParams({
tokenIn: tokenIn,
tokenOut: tokenOut,
fee: fee,
recipient: vault,
deadline: block.timestamp,
amountIn: amountIn,
amountOutMinimum: minAmountOut,
sqrtPriceLimitX96: 0
});
// Execute swap
amountOut = swapRouter.exactInputSingle(params);
require(amountOut >= minAmountOut, "Insufficient output");
}
function protocol() external pure override returns (string memory) {
return "Uniswap V3";
}
}Example: Aave V3 Adapter
solidity
contract AaveV3Adapter is IProtocolAdapter {
IPool public immutable lendingPool;
address public immutable vault;
constructor(address _lendingPool, address _vault) {
lendingPool = IPool(_lendingPool);
vault = _vault;
}
modifier onlyVault() {
require(msg.sender == vault, "Not vault");
_;
}
function supply(
address asset,
uint256 amount
) external onlyVault {
IERC20(asset).approve(address(lendingPool), amount);
lendingPool.supply(asset, amount, vault, 0);
}
function withdraw(
address asset,
uint256 amount
) external onlyVault returns (uint256) {
return lendingPool.withdraw(asset, amount, vault);
}
function protocol() external pure override returns (string memory) {
return "Aave V3";
}
}Gas Optimization
Batch Actions
Process multiple actions in a single transaction:
solidity
function _executeActions(Action[] memory actions)
internal
returns (address[] memory protocols)
{
protocols = new address[](actions.length);
for (uint256 i = 0; i < actions.length; i++) {
// Cache to memory
Action memory action = actions[i];
// Single SLOAD for adapter lookup
address adapter = adapters[action.protocolId];
require(adapter != address(0), "Unknown protocol");
// Execute
(bool success, bytes memory result) = adapter.call(
abi.encodePacked(action.selector, action.params)
);
require(success, string(result));
protocols[i] = adapter;
}
}Storage Optimization
solidity
// Pack related variables
struct VaultState {
uint128 nonce; // 16 bytes
uint64 lastExecuted; // 8 bytes
uint64 totalExecutions; // 8 bytes
// Total: 32 bytes (single slot)
}Circuit Breakers
Implement safety limits:
solidity
contract CircuitBreaker {
uint256 public maxActionsPerIntent = 10;
uint256 public maxValuePerAction = 1_000_000e18;
uint256 public dailyVolumeLimit = 10_000_000e18;
uint256 public dailyVolume;
uint256 public lastResetTimestamp;
function _checkCircuitBreakers(Intent memory intent) internal {
// Max actions check
require(
intent.actions.length <= maxActionsPerIntent,
"Too many actions"
);
// Daily volume check
if (block.timestamp > lastResetTimestamp + 1 days) {
dailyVolume = 0;
lastResetTimestamp = block.timestamp;
}
require(
dailyVolume < dailyVolumeLimit,
"Daily limit exceeded"
);
}
}Events for Monitoring
solidity
event IntentExecuted(
uint256 indexed nonce,
bytes32 indexed intentHash,
address[] protocols,
bool success
);
event ActionExecuted(
uint256 indexed nonce,
uint256 indexed actionIndex,
bytes32 protocolId,
bytes4 selector,
bool success
);
event IntentFailed(
uint256 indexed nonce,
bytes32 indexed intentHash,
string reason
);
event EmergencyPause(
address indexed caller,
uint256 timestamp
);Testing Checklist
- [ ] Signature verification with valid Canton keys
- [ ] Rejection of invalid signatures
- [ ] Nonce replay protection
- [ ] Deadline expiration handling
- [ ] Action execution success/failure
- [ ] Adapter registration and updates
- [ ] Emergency pause functionality
- [ ] Emergency withdrawal
- [ ] Gas optimization benchmarks
- [ ] Reentrancy attack prevention
