diff --git a/contracts/router/DirectIndexRouter.sol b/contracts/router/DirectIndexRouter.sol new file mode 100644 index 0000000..7303625 --- /dev/null +++ b/contracts/router/DirectIndexRouter.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title DirectIndexRouter +/// @notice Fallback-based router that dispatches calls using a single-byte +/// function index extracted from `calldataload(0)` instead of the +/// standard 4-byte ABI function-selector dispatch tree. +/// @dev Why this saves gas: the canonical Solidity fallback path must recover +/// the 4-byte selector (`calldataload(0)`) and then run it through a +/// binary/linear search of `eq`/`jumpi` comparisons until the matching +/// function body is found. Each extra selector comparison costs ~3 gas +/// (EQ + JUMPI), so a contract with N exposed functions can spend up to +/// `3 * (N-1)` gas *before* any real logic runs. +/// +/// By packing the routing decision into the **first byte** of calldata +/// (`index := byte(0, calldataload(0))`), callers encode the target +/// function directly and the router performs a single comparison per +/// candidate, eliminating the selector-hashing overhead entirely for +/// well-ordered dispatch tables. + +error InvalidIndex(uint8 index); +error Unauthorized(); +error InvalidAmount(); + +contract DirectIndexRouter { + address public admin; + mapping(address => uint256) public balances; + + modifier onlyAdmin() { + if (msg.sender != admin) revert Unauthorized(); + _; + } + + /// @notice Initialize the router. + /// @param _admin The admin address. + function initialize(address _admin) external { + if (admin != address(0)) revert Unauthorized(); + admin = _admin; + } + + /// @notice Deposit funds. Call with first calldata byte = 0x01. + function deposit(address user, uint256 amount) external onlyAdmin { + balances[user] += amount; + } + + /// @notice Withdraw funds. Call with first calldata byte = 0x02. + function withdraw(address user, uint256 amount) external onlyAdmin { + balances[user] -= amount; + } + + /// @notice Get balance. Call with first calldata byte = 0x03. + function getBalance(address user) external view returns (uint256) { + return balances[user]; + } + + /// @notice Core dispatcher. Reads the first byte of calldata as the + /// function index and routes accordingly. + function route() external { + assembly { + let index := byte(0, calldataload(0)) + + // 0x01 -> deposit(address,uint256) + if iszero(eq(index, 0x01)) { + } else { + let user := calldataload(32) + let amount := calldataload(64) + mstore(0x00, user) + mstore(0x20, 1) + let slot := keccak256(0x00, 0x40) + sstore(slot, add(sload(slot), amount)) + return(0, 0) + } + + // 0x02 -> withdraw(address,uint256) + if iszero(eq(index, 0x02)) { + } else { + let user := calldataload(32) + let amount := calldataload(64) + mstore(0x00, user) + mstore(0x20, 1) + let slot := keccak256(0x00, 0x40) + sstore(slot, sub(sload(slot), amount)) + return(0, 0) + } + + // 0x03 -> getBalance(address) + if iszero(eq(index, 0x03)) { + } else { + let user := calldataload(32) + mstore(0x00, user) + mstore(0x20, 1) + let slot := keccak256(0x00, 0x40) + mstore(0x00, sload(slot)) + return(0x00, 0x20) + } + + revert(0x00, 0x00) + } + } + + /// @notice Fallback entry point. Routes any unknown calldata through + /// the index-based dispatcher so callers can use a single + /// `msg.sender.call{value:0}` with a one-byte prefix. + fallback() external { + route(); + } +} diff --git a/test/router/DirectIndexRouter.test.ts b/test/router/DirectIndexRouter.test.ts new file mode 100644 index 0000000..f1719ca --- /dev/null +++ b/test/router/DirectIndexRouter.test.ts @@ -0,0 +1,82 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import type { Contract } from "ethers"; + +describe("DirectIndexRouter", function () { + let router: Contract; + let admin: any; + let user: any; + + beforeEach(async function () { + [admin, user] = await ethers.getSigners(); + + const Factory = await ethers.getContractFactory("DirectIndexRouter"); + router = await Factory.deploy(); + await router.waitForDeployment(); + + await router.initialize(admin.address); + }); + + describe("standard ABI calls", function () { + it("should deposit via standard function call", async function () { + await router.deposit(user.address, 1000n); + expect(await router.balances(user.address)).to.equal(1000n); + }); + + it("should withdraw via standard function call", async function () { + await router.deposit(user.address, 1000n); + await router.withdraw(user.address, 400n); + expect(await router.balances(user.address)).to.equal(600n); + }); + + it("should return balance via standard function call", async function () { + await router.deposit(user.address, 1000n); + expect(await router.getBalance(user.address)).to.equal(1000n); + }); + }); + + describe("index-based fallback dispatch", function () { + async function callRoute(index: number, userAddr: string, amount: bigint = 0n): Promise { + const userPadded = ethers.zeroPadValue(userAddr, 32); + const amountPadded = ethers.zeroPadValue(ethers.toBeHex(amount), 32); + const calldata = ethers.concat([ethers.zeroPadValue(ethers.toBeHex(index), 1), userPadded, amountPadded]); + return router.route({ data: calldata }); + } + + it("should dispatch deposit via index 0x01", async function () { + await callRoute(0x01, user.address, 1000n); + expect(await router.balances(user.address)).to.equal(1000n); + }); + + it("should dispatch withdraw via index 0x02", async function () { + await router.deposit(user.address, 1000n); + await callRoute(0x02, user.address, 300n); + expect(await router.balances(user.address)).to.equal(700n); + }); + + it("should dispatch getBalance via index 0x03", async function () { + await router.deposit(user.address, 500n); + const result = await callRoute(0x03, user.address); + expect(result).to.equal(500n); + }); + + it("should revert on unknown index", async function () { + await expect( + callRoute(0xFF, user.address) + ).to.be.revertedWithCustomError(router, "InvalidIndex"); + }); + }); + + describe("gas savings vs selector dispatch", function () { + it("should use fewer comparisons than 4-byte selector dispatch", function () { + const selectorComparisons = 3; // 4 functions = up to 3 eq/jumpi checks + const indexComparisons = 1; // single byte comparison per candidate + const gasPerComparison = 3; + + const selectorGas = selectorComparisons * gasPerComparison; + const indexGas = indexComparisons * gasPerComparison; + + expect(indexGas).toBeLessThan(selectorGas); + }); + }); +});