Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
92 changes: 92 additions & 0 deletions contracts/proxy/MinimalERC1967Proxy.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title MinimalERC1967Proxy
/// @notice A minimal ERC-1967 compliant upgradeable proxy written entirely
/// in Yul assembly. Reads the implementation address from the standard
/// ERC-1967 storage slot on every call and forwards via `delegatecall`,
/// avoiding the ~100-300 gas of high-level abstraction overhead (storage
/// struct access, library calls, redundant zero-checks) that typical
/// OpenZeppelin-style proxies add to every forwarded call.
/// @dev ERC-1967 (https://eips.ethereum.org/EIPS/eip-1967) fixes the
/// implementation address at storage slot
/// `bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)` so
/// that block explorers, wallets, and other tooling can locate the
/// upgrade target without needing the proxy's ABI. Using `keccak256(...) - 1`
/// (rather than the hash itself) is the standard's own safeguard against a
/// contract author choosing a colliding slot deliberately — subtracting 1
/// makes the slot not itself the preimage of any known hash.
contract MinimalERC1967Proxy {
/// @dev bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
bytes32 internal constant _IMPLEMENTATION_SLOT =
0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc;

/// @notice Emitted whenever the implementation address changes,
/// per the ERC-1967 spec (`Upgraded(address indexed implementation)`).
event Upgraded(address indexed implementation);

/// @param implementation_ The initial implementation address.
constructor(address implementation_) {
require(implementation_.code.length > 0, "MinimalERC1967Proxy: not a contract");
bytes32 slot = _IMPLEMENTATION_SLOT;
// Safety: writes to a single, standard, well-known storage slot
// that this contract exclusively owns; not user-influenced.
assembly {
sstore(slot, implementation_)
}
emit Upgraded(implementation_);
}

/// @dev Catches all calls (including plain ETH transfers) and forwards
/// them via `delegatecall` to whatever address is currently stored at
/// `_IMPLEMENTATION_SLOT`.
fallback() external payable {
_delegate();
}

receive() external payable {
_delegate();
}

/// @notice Reads the implementation address from the ERC-1967 slot and
/// forwards the current call's calldata to it via `delegatecall`,
/// relaying the callee's return data (or revert reason) unchanged.
/// @dev Safety: `sload` reads only `_IMPLEMENTATION_SLOT` — a fixed,
/// non-user-controlled slot — so a caller cannot redirect the
/// delegatecall target via calldata. The function never returns to
/// Solidity control flow; it always terminates via `return`/`revert`
/// inside the assembly block, matching the pattern used by
/// `YulProxyForwarder` in this same directory (that contract instead
/// bakes the implementation into an immutable, so cannot be upgraded —
/// this one trades that immutability for the ERC-1967-mandated
/// upgrade path).
/// Gas: one SLOAD for the implementation address (2100 cold / 100
/// warm) plus `calldatacopy`/`delegatecall`/`returndatacopy`,
/// forwarding all remaining gas — no additional Solidity-level
/// abstraction (no storage struct, no library dispatch, no redundant
/// zero-address check beyond what `delegatecall` itself already
/// reverts on when given a non-contract target).
function _delegate() internal {
assembly {
let impl := sload(_IMPLEMENTATION_SLOT)

// Copy incoming calldata to memory location 0x00.
calldatacopy(0, 0, calldatasize())

// Forward as a delegatecall, preserving msg.sender/msg.value
// semantics of the original caller.
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

// Copy the returned data (success or revert reason).
returndatacopy(0, 0, returndatasize())

switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}
109 changes: 109 additions & 0 deletions contracts/utils/YulMappingSlot.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title YulMappingSlot
/// @notice Gas-optimized storage slot calculator for a single-level
/// `mapping(address => uint256)`, written directly in Yul scratch-space
/// operations instead of relying on `abi.encode` + `keccak256`.
/// @dev Standard Solidity mapping layout: for a mapping declared at storage
/// slot `baseSlot`, the slot of `mapping[key]` is
/// `keccak256(abi.encode(key, baseSlot))` — the key written first (left,
/// bytes 0-31), the mapping's own slot second (right, bytes 32-63), then
/// hashed as a single 64-byte region. This matches Solidity's own codegen
/// for a top-level mapping (see the Solidity docs, "Layout of State
/// Variables in Storage" > "Mappings and Dynamic Arrays"), so the value at
/// the returned slot is byte-for-byte the same value the compiler itself
/// would read for `mapping[key]`.
library YulMappingSlot {
/// @notice Computes the storage slot of `mapping[key]` for a
/// `mapping(address => uint256)` declared at `baseSlot`.
/// @param key The mapping key (an address, left-padded to 32 bytes as
/// `uint256` per Solidity's ABI encoding rules).
/// @param baseSlot The storage slot the mapping itself occupies.
/// @return slot The storage slot holding `mapping[key]`'s value.
function computeSlot(address key, uint256 baseSlot) internal pure returns (bytes32 slot) {
// [key, baseSlot] -> [slot]
// Safety: writes only to scratch memory (0x00-0x40, reserved for
// this exact purpose by the Solidity ABI spec); no storage access,
// no external calls; does not touch or rely on the free memory
// pointer (0x40), so it is safe to call from any context, including
// inside another assembly block that has already written to
// scratch space for an unrelated purpose (each call re-initializes
// both words before hashing).
// Gas: two MSTOREs (3 gas each) + keccak256(0x00, 0x40) (30 gas +
// 6 gas/word * 2 words = 42 gas) instead of `abi.encode`'s ABI
// encoder overhead (memory allocation, free-pointer bump, and a
// dynamic-length-aware copy loop) for the same two-word input.
assembly {
mstore(0x00, key)
mstore(0x20, baseSlot)
slot := keccak256(0x00, 0x40)
}
}

/// @notice Reads `mapping[key]`'s value directly via the computed slot.
/// @param key The mapping key.
/// @param baseSlot The storage slot the mapping itself occupies.
/// @return value The value stored at `mapping[key]`.
function readValue(address key, uint256 baseSlot) internal view returns (uint256 value) {
bytes32 slot = computeSlot(key, baseSlot);
// [slot] -> [value]
// Safety: single SLOAD at the just-computed slot; no other state
// access.
// Gas: SLOAD (2100 cold / 100 warm).
assembly {
value := sload(slot)
}
}

/// @notice Writes `value` to `mapping[key]` directly via the computed
/// slot.
/// @param key The mapping key.
/// @param baseSlot The storage slot the mapping itself occupies.
/// @param value The value to store.
function writeValue(address key, uint256 baseSlot, uint256 value) internal {
bytes32 slot = computeSlot(key, baseSlot);
// [slot, value] -> []
// Safety: single SSTORE at the just-computed slot; no other state
// access; no reentrancy surface (no external calls made).
// Gas: SSTORE (20000 cold-zero-to-nonzero / 2900 warm, per EIP-2929
// + EIP-2200 rules — identical cost profile to a compiler-generated
// mapping write to the same slot).
assembly {
sstore(slot, value)
}
}
}

/// @title YulMappingSlotConsumer
/// @notice Example contract pairing a real `mapping(address => uint256)`
/// with `YulMappingSlot`, so the library's output can be checked against
/// the compiler's own mapping storage layout for the exact same slot.
contract YulMappingSlotConsumer {
// Storage slot 0.
mapping(address => uint256) public balances;

/// @notice Returns the storage slot `YulMappingSlot` computes for
/// `balances[user]` (`balances` occupies slot 0).
function slotFor(address user) external pure returns (bytes32) {
return YulMappingSlot.computeSlot(user, 0);
}

/// @notice Reads `balances[user]` using the Yul-computed slot instead
/// of the compiler-generated mapping accessor.
function readAssembly(address user) external view returns (uint256) {
return YulMappingSlot.readValue(user, 0);
}

/// @notice Writes `balances[user]` using the Yul-computed slot instead
/// of a normal Solidity assignment.
function writeAssembly(address user, uint256 value) external {
YulMappingSlot.writeValue(user, 0, value);
}

/// @notice Standard Solidity mapping write, for parity testing against
/// `writeAssembly`/`readAssembly`.
function writeSolidity(address user, uint256 value) external {
balances[user] = value;
}
}
143 changes: 143 additions & 0 deletions contracts/vault/PackedVaultEngine.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title PackedVaultEngine
/// @notice Multi-asset vault accounting engine that packs a user's balance,
/// asset id, and lock timestamp for a given position into a single
/// `bytes32` storage word, using inline Yul bit-masking/shifts to update
/// individual fields without disturbing the others.
/// @dev Bit layout of a packed position word (bit 0 = least significant):
/// [0, 128) balance (uint128)
/// [128, 160) assetId (uint32)
/// [160, 224) lockTimestamp (uint64, unix seconds)
/// [224, 256) unused/reserved
/// Managing these as three separate storage slots (as a Solidity struct
/// with three fields normally would) costs a `SLOAD`/`SSTORE` per field per
/// access; packing them into one word means every position read/write is
/// exactly one `SLOAD`/`SSTORE`, regardless of how many of the three
/// logical fields are touched.
///
/// Arithmetic itself (the `+`/`-` on `balance`) is done in plain, checked
/// Solidity `uint128` math — which reverts on overflow/underflow exactly
/// like any other Solidity arithmetic — and only the *field replacement*
/// (splicing the new balance into the existing word without touching the
/// assetId/lockTimestamp bits) is done via Yul mask/shift. Reimplementing
/// overflow-checked addition by hand in assembly would be strictly riskier
/// than using the compiler's own checked arithmetic for that part.
contract PackedVaultEngine {
error PositionLocked(uint64 lockTimestamp, uint64 currentTimestamp);
error InsufficientBalance(uint128 balance, uint128 requested);
error ZeroAmount();

uint256 private constant _BALANCE_MASK = (uint256(1) << 128) - 1;
uint256 private constant _ASSET_ID_SHIFT = 128;
uint256 private constant _ASSET_ID_MASK = (uint256(type(uint32).max)) << _ASSET_ID_SHIFT;
uint256 private constant _LOCK_TIMESTAMP_SHIFT = 160;
uint256 private constant _LOCK_TIMESTAMP_MASK = (uint256(type(uint64).max)) << _LOCK_TIMESTAMP_SHIFT;

/// @dev positions[user][assetId] => packed(balance, assetId, lockTimestamp)
mapping(address => mapping(uint32 => bytes32)) private _positions;

event Deposited(address indexed user, uint32 indexed assetId, uint128 amount, uint128 newBalance, uint64 lockTimestamp);
event Withdrawn(address indexed user, uint32 indexed assetId, uint128 amount, uint128 newBalance);

/// @notice Deposits `amount` of `assetId` for `msg.sender`, extending
/// the position's lock to `block.timestamp + lockDuration`.
/// @param assetId The asset identifier for this position.
/// @param amount The amount to add to the position's balance.
/// @param lockDuration Seconds from now the position becomes withdrawable.
function deposit(uint32 assetId, uint128 amount, uint64 lockDuration) external {
if (amount == 0) revert ZeroAmount();

bytes32 word = _positions[msg.sender][assetId];
uint128 currentBalance = uint128(uint256(word) & _BALANCE_MASK);
// Checked uint128 addition — reverts on overflow, matching what a
// normal (unpacked) Solidity uint128 field would do.
uint128 newBalance = currentBalance + amount;
uint64 lockTimestamp = uint64(block.timestamp) + lockDuration;

bytes32 newWord = _pack(newBalance, assetId, lockTimestamp);
_positions[msg.sender][assetId] = newWord;

emit Deposited(msg.sender, assetId, amount, newBalance, lockTimestamp);
}

/// @notice Withdraws `amount` of `assetId` from `msg.sender`'s position.
/// Reverts if the position is still locked or the balance is insufficient.
/// @param assetId The asset identifier for this position.
/// @param amount The amount to subtract from the position's balance.
function withdraw(uint32 assetId, uint128 amount) external {
if (amount == 0) revert ZeroAmount();

bytes32 word = _positions[msg.sender][assetId];
uint128 currentBalance = uint128(uint256(word) & _BALANCE_MASK);
uint64 lockTimestamp = uint64((uint256(word) & _LOCK_TIMESTAMP_MASK) >> _LOCK_TIMESTAMP_SHIFT);

if (block.timestamp < lockTimestamp) {
revert PositionLocked(lockTimestamp, uint64(block.timestamp));
}
if (amount > currentBalance) {
revert InsufficientBalance(currentBalance, amount);
}

// Checked uint128 subtraction — reverts on underflow (already
// guarded above, but kept for defense-in-depth / clarity).
uint128 newBalance = currentBalance - amount;

bytes32 newWord = _pack(newBalance, assetId, lockTimestamp);
_positions[msg.sender][assetId] = newWord;

emit Withdrawn(msg.sender, assetId, amount, newBalance);
}

/// @notice Returns the unpacked fields of `user`'s position in `assetId`.
function getPosition(address user, uint32 assetId)
external
view
returns (uint128 balance, uint32 storedAssetId, uint64 lockTimestamp)
{
bytes32 word = _positions[user][assetId];
(balance, storedAssetId, lockTimestamp) = _unpack(word);
}

/// @dev Packs `(balance, assetId, lockTimestamp)` into a single word.
/// Safety: pure bit arithmetic on function arguments already narrowed
/// to their field widths by the Solidity type system (uint128/uint32/
/// uint64) — no unmasked write can bleed into an adjacent field's bits.
function _pack(uint128 bal, uint32 assetId, uint64 lockTimestamp)
private
pure
returns (bytes32 word)
{
uint256 balanceMask = _BALANCE_MASK;
uint256 assetIdField = uint256(assetId);
uint256 lockTimestampField = uint256(lockTimestamp);
assembly {
word := or(
and(bal, balanceMask),
or(
shl(_ASSET_ID_SHIFT, and(assetIdField, 0xffffffff)),
shl(_LOCK_TIMESTAMP_SHIFT, and(lockTimestampField, 0xffffffffffffffff))
)
)
}
}

/// @dev Unpacks a word into `(balance, assetId, lockTimestamp)`.
/// Safety: masks isolate each field's bit range before shifting, so a
/// value in one field can never leak into another field's return value.
function _unpack(bytes32 word)
private
pure
returns (uint128 bal, uint32 assetId, uint64 lockTimestamp)
{
uint256 balanceMask = _BALANCE_MASK;
uint256 assetIdMask = _ASSET_ID_MASK;
uint256 lockTimestampMask = _LOCK_TIMESTAMP_MASK;
assembly {
bal := and(word, balanceMask)
assetId := shr(_ASSET_ID_SHIFT, and(word, assetIdMask))
lockTimestamp := shr(_LOCK_TIMESTAMP_SHIFT, and(word, lockTimestampMask))
}
}
}
8 changes: 8 additions & 0 deletions foundry.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"lib/forge-std": {
"tag": {
"name": "v1.16.2",
"rev": "bf647bd6046f2f7da30d0c2bf435e5c76a780c1b"
}
}
}
2 changes: 1 addition & 1 deletion foundry.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
src = "contracts"
test = "test"
out = "out"
libs = []
libs = ["lib"]
solc_version = "0.8.20"
optimizer = true
optimizer_runs = 1000
7 changes: 7 additions & 0 deletions gasguard-cli/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions gasguard-cli/src/transformers/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod storage_packer;
pub mod transient_lock;
Loading
Loading