Personalized swap fees, proven onchain
Uniswap v4 hooks let you run custom logic on every swap. To make decisions based on real user activity, your hook needs access to reliable onchain data. SXT Chain's Proof of SQL lets your hook query blockchain data — trading volume, staking history, transaction count — and receive a cryptographically proven result. This enables you to give your most active users lower fees, with no trusted third parties and no offchain infrastructure.
You define your pool conditions. SXT proves them.
Every liquidity pool has a different community. Some protocols want to reward long-term token holders. Others care about trading volume, governance participation, or staking history. With Uniswap v4 hooks and SXT's Proof of SQL, you write the condition that matters to your protocol — and SXT proves it against real onchain data before any fee is applied.
No oracles. No offchain infrastructure. No manually maintained allowlists. Your fee logic lives entirely onchain, and every discount decision comes with a cryptographic proof that the underlying data is correct.
The SQL is the only thing you change. The templates below handle all the SXT query plumbing, the callback wiring, and the hook adapter interface. You bring the condition — volume threshold, staking balance, token holding period, transaction count — and swap it into the query. The rest is already built.
Choose a Template
Each template is a different pattern for how discounts are computed, stored, and applied. Pick the one that matches your product UX.
Loyalty Discount
Check your score, then swap at a discount.
How it works
- The user (or your frontend) calls check eligibility on the contract, asking it to look up the user’s onchain history.
- The contract sends a SQL query to SXT. For example: “How much volume has this wallet traded in the past 30 days?”
- SXT runs the query against real blockchain data and returns the result with a cryptographic proof that the answer is correct.
- The contract receives the verified result and caches a discount tier like “this wallet qualifies for 20% off swap fees.”
- When the user swaps on Uniswap, the hook reads the cached discount and automatically reduces the fee.
This is the most straightforward pattern. Think of it like checking your frequent flyer status before booking a flight: you look up your tier, and then your discount is applied at checkout. The two steps (check score, then swap) make the system predictable and easy to test.
Because the discount is cached onchain after verification, the swap itself is fast and cheap. This also makes it the easiest template to audit, since there are no hidden state transitions or async dependencies in the swap path.
Download Template
Foundry-oriented scaffold with Solidity, docs, SQL examples, and security checklist. You still need to implement decode + swap wiring before production use.
100 SXT query deposit. Unused deposit is refunded to refundTo after fulfillment.beforeSwap function so the pool actually uses the discount when calculating fees.Generated Solidity Preview
This is the main contract from the template package. It includes a base class with SXT query plumbing, a tiered discount policy, and the template-specific logic. The downloaded ZIP includes this file plus all supporting documentation.
// SPDX-License-Identifier: MITpragma solidity 0.8.30;
import {ProofOfSqlTable} from "sxt-proof-of-sql/src/client/PoSQLTable.post.sol";
/** * --------------------------------------------------------------------------- * SXT + Uniswap v4 Hook Discount Template * --------------------------------------------------------------------------- * * Two-step flow: refresh query state first, then swap using cached discount. * * HOW IT WORKS: * 1. Your contract calls requestQuery() on SXT's QueryRouter with a SQL query * and a proof plan. A deposit (PAYMENT_AMOUNT) covers execution cost. * 2. The SXT network executes the query against indexed blockchain data and * generates a cryptographic proof that the result is correct. * 3. SXT's CallbackExecutor calls queryCallback() on this contract with the * verified result. No keeper or relayer infrastructure is needed. * 4. The callback decodes the result, computes a discount tier, and stores it. * 5. Your Uniswap v4 hook calls quoteDiscountedFee() during beforeSwap to * look up the user's discount and return an adjusted fee to PoolManager. * * BEFORE DEPLOYING: * - Replace QUERY_PLAN with a real proof plan generated from your SQL. * - Update _decodeScore() if your query result schema differs from DECIMAL(75,0). * - Wire the TODO sections into your Uniswap v4 hook / swap router. * - See docs/REQUIRED-REPLACEMENTS.md for the full list. * * Chain target: ethereum * * References: * - Uniswap v4 Hooks: https://docs.uniswap.org/contracts/v4/concepts/hooks * - Uniswap v4 PoolManager: https://docs.uniswap.org/contracts/v4/reference/core/interfaces/IPoolManager * - SXT Documentation: https://docs.spaceandtime.io/ * * SQL used to generate this proof plan: * * SELECT COALESCE(SUM(AMOUNT0 * (2 * CAST(AMOUNT0 > 0 AS BIGINT) - 1)), 0) AS SCORE* FROM YOUR_SCHEMA.YOUR_UNISWAP_SWAP_TABLE* WHERE SENDER = $1 */
interface IERC20 { function transfer(address to, uint256 value) external returns (bool); function transferFrom(address from, address to, uint256 value) external returns (bool); function approve(address spender, uint256 value) external returns (bool);}
interface IQueryRouter { struct Query { bytes32 version; bytes innerQuery; bytes parameters; bytes metadata; }
struct Callback { uint256 maxGasPrice; uint64 gasLimit; address callbackContract; bytes4 selector; bytes callbackData; }
struct Payment { uint256 paymentAmount; address refundTo; uint64 timeout; }
function requestQuery(Query calldata query, Callback calldata callback, Payment calldata payment) external returns (bytes32 queryId);}
/// @dev Called by SXT's CallbackExecutor to deliver verified query results.interface IQueryCallback { function queryCallback(bytes32 queryId, bytes calldata queryResult, bytes calldata callbackData) external;}
/// @dev Optional interface — your Uniswap v4 hook can call this to get the discounted fee.interface IUniswapDiscountHookAdapter { function quoteUserFee(address user, uint24 baseFee) external view returns (uint24 finalFee);}
/** * @title SxtDiscountBase * @notice Base contract that handles SXT query requests and callback processing. * * Subcontracts (RefreshThenSwap, AsyncIntent, VoucherNft) inherit this and implement * queryCallback() to store discount state in their own way. Your Uniswap v4 hook * reads that state during beforeSwap to return an adjusted fee. * * Query flow: * requestRefresh(user) * → transferFrom(payer, PAYMENT_AMOUNT) to cover query deposit * → QueryRouter.requestQuery(sql, proofPlan, callback, payment) * → SXT executes query offchain + generates proof * → CallbackExecutor.queryCallback(queryId, verifiedResult) * → subcontract decodes result + updates discount state */abstract contract SxtDiscountBase is IQueryCallback { error CallbackOnly(); error InvalidQuery(); error InvalidProofResult(); error InvalidQueryPlan();
// ── SXT infrastructure (set at generation time, immutable) ───────────
/// @dev Entry point for SXT queries. See: https://docs.spaceandtime.io/ address public constant QUERY_ROUTER = 0x220a7036a815a1Bd4A7998fb2BCE608581fA2DbB;
/// @dev The only address allowed to call queryCallback(). MUST be checked in every callback. address public constant CALLBACK_EXECUTOR = 0xaCf075862425A0c839844369ac20e334B3710e47;
/// @dev SXT token used for query deposit payments. address public constant PAYMENT_TOKEN = 0xE6Bfd33F52d82Ccb5b37E16D3dD81f9FFDAbB195;
/// @dev Query version hash registered in QueryRouter ("latest"). bytes32 public constant VERSION = 0x5d7c83ff6d08efb2f110018d8b6814a4719a93273a3ea09ced5cfe0d5db1d001;
// ── Query execution parameters (tune for your use case) ─────────────
/// @dev Deposit sent to QueryRouter per query. Unused portion is refunded to refundTo. uint256 public constant PAYMENT_AMOUNT = 100000000000000000000;
/// @dev Maximum gas price for callback execution. uint256 public constant MAX_GAS_PRICE = 1_000_000;
/// @dev Gas limit for callback. Increase if your callback does heavy work (e.g. swap execution). uint64 public constant GAS_LIMIT = 1_000_000;
/// @dev Proof plan bytes generated from your SQL via commitments_v1_evmProofPlan. /// PLACEHOLDER — replace with real proof plan before deploying. bytes public constant QUERY_PLAN = hex"1234";
/// @dev Maps queryId → user address. Consumed (deleted) when callback arrives. mapping(bytes32 => address) public queryUser;
event QueryRequested(bytes32 indexed queryId, address indexed user); event QueryApplied(bytes32 indexed queryId, address indexed user, uint256 score, uint16 bps);
/// @notice Request a discount eligibility check for a user. /// @dev Permissionless — anyone can call this to sponsor a refresh for any user. /// The query deposit is taken from msg.sender and refunded to msg.sender after execution. /// @param user The wallet address to check eligibility for (substituted as $1 in SQL). function requestRefresh(address user) public returns (bytes32 queryId) { queryId = _requestRefresh(user, abi.encode(user)); }
/// @dev Internal query path that supports custom callback data (used by intent template /// to pass intentId through the callback). function _requestRefresh(address user, bytes memory callbackData) internal returns (bytes32 queryId) { if (QUERY_PLAN.length < 32) revert InvalidQueryPlan(); _payForQuery(msg.sender);
// Encode the user address as the $1 parameter for the SQL query. // Format: ParamsBuilder.varBinary(address) — one param, type 11 (varbinary), 20 bytes. bytes memory serializedParams = abi.encodePacked(uint64(1), uint32(11), uint64(20), bytes20(user));
IQueryRouter.Query memory q = IQueryRouter.Query({ version: VERSION, innerQuery: QUERY_PLAN, parameters: serializedParams, metadata: hex"" }); IQueryRouter.Callback memory cb = IQueryRouter.Callback({ maxGasPrice: MAX_GAS_PRICE, gasLimit: GAS_LIMIT, callbackContract: address(this), selector: IQueryCallback.queryCallback.selector, callbackData: callbackData }); IQueryRouter.Payment memory payment = IQueryRouter.Payment({ paymentAmount: PAYMENT_AMOUNT, refundTo: msg.sender, timeout: uint64(block.timestamp + 1 hours) });
queryId = IQueryRouter(QUERY_ROUTER).requestQuery(q, cb, payment); queryUser[queryId] = user; emit QueryRequested(queryId, user); }
/// @dev Consumes and returns the user for a queryId. Reverts if already consumed or unknown. function _consumeQueryUser(bytes32 queryId) internal returns (address user) { user = queryUser[queryId]; if (user == address(0)) revert InvalidQuery(); delete queryUser[queryId]; }
/// @dev Transfers query deposit from payer and approves QueryRouter to spend it. function _payForQuery(address payer) internal { _safeTransferFrom(PAYMENT_TOKEN, payer, address(this), PAYMENT_AMOUNT); _forceApprove(PAYMENT_TOKEN, QUERY_ROUTER, PAYMENT_AMOUNT); }
/// @dev Maps a numeric score to a discount in basis points (100 bps = 1%). /// Replace thresholds and percentages to match your protocol's economics. function _tieredBps(uint256 score) internal pure returns (uint16 bps) { if (score >= 500000000000) return uint16(3500); if (score >= 100000000000) return uint16(2000); if (score >= 10000000000) return uint16(1000); return 0; }
/// @dev Decodes the first column of the query result as a DECIMAL(75,0) value. /// This assumes your SQL returns a single numeric column (e.g. SUM(...) AS SCORE). /// If your query returns a different type or multiple columns, replace this function. function _decodeScore(bytes calldata queryResult) internal pure returns (uint256 score) { if (queryResult.length == 0) revert InvalidProofResult(); (, ProofOfSqlTable.Table memory tableResult) = ProofOfSqlTable.deserializeFromBytes(queryResult); uint256[] memory scoreColumn = ProofOfSqlTable.readDecimal75Column(tableResult, 0); if (scoreColumn.length == 0) return 0; score = scoreColumn[0]; }
/// @dev Reduces baseFee by discountBps. Example: baseFee=3000 (0.30%), discountBps=2000 (20% off) → 2400 (0.24%). function _applyFeeBps(uint24 baseFee, uint16 discountBps) internal pure returns (uint24) { if (discountBps == 0) return baseFee; return uint24(uint256(baseFee) * (10_000 - discountBps) / 10_000); }
/// @dev Token transfer helpers to handle non-standard ERC20 implementations. function _safeTransferFrom(address token, address from, address to, uint256 value) internal { (bool ok, bytes memory ret) = token.call(abi.encodeWithSelector(IERC20.transferFrom.selector, from, to, value)); require(ok && (ret.length == 0 || abi.decode(ret, (bool))), "TRANSFER_FROM_FAILED"); }
function _forceApprove(address token, address spender, uint256 value) internal { (bool ok, bytes memory ret) = token.call(abi.encodeWithSelector(IERC20.approve.selector, spender, value)); if (ok && (ret.length == 0 || abi.decode(ret, (bool)))) return; (bool okReset, bytes memory resetRet) = token.call(abi.encodeWithSelector(IERC20.approve.selector, spender, 0)); require(okReset && (resetRet.length == 0 || abi.decode(resetRet, (bool))), "APPROVE_RESET_FAILED"); (bool okSet, bytes memory setRet) = token.call(abi.encodeWithSelector(IERC20.approve.selector, spender, value)); require(okSet && (setRet.length == 0 || abi.decode(setRet, (bool))), "APPROVE_SET_FAILED"); }}
contract RefreshThenSwapDiscountTemplate is SxtDiscountBase { mapping(address => uint16) public discountBps; mapping(address => uint256) public lastUpdatedAt;
/// @notice Callback path updates user discount cache. /// @dev In production, ensure callback sender + query lifecycle checks are complete. function queryCallback(bytes32 queryId, bytes calldata queryResult, bytes calldata) external override { if (msg.sender != CALLBACK_EXECUTOR) revert CallbackOnly(); address user = _consumeQueryUser(queryId);
uint256 score = _decodeScore(queryResult); uint16 bps = _tieredBps(score); discountBps[user] = bps; lastUpdatedAt[user] = block.timestamp;
emit QueryApplied(queryId, user, score, bps); }
/// @notice Hook adapter can call this during beforeSwap. function quoteDiscountedFee(address user, uint24 baseFee) external view returns (uint24) { return _applyFeeBps(baseFee, discountBps[user]); }
/// @notice Optional guard to expire stale cached discounts. /// TODO: enforce max age in quote path if your model requires strict recency. function discountAgeSeconds(address user) external view returns (uint256) { if (lastUpdatedAt[user] == 0) return type(uint256).max; return block.timestamp - lastUpdatedAt[user]; }}