Robinhood MainnetChain ID: 4663
Contract detail

SpendGatedSignerFeeCalculator

SpendGatedSignerFeeCalculator is a verified Robinhood Chain smart contract at 0xc65fc67f…2bdc9891 with 72 indexed transactions.

Address type
Smart contract
Transactions
72
First indexed block
#28345537
Latest indexed block
#54476463
Emitted events
885
Verification
Verified
Compiler
v0.8.26+commit.8a97fa7a
Verification match
full
Creation transaction
0xfda32aa8…458c36eb
Created at block
#28345521
Language
solidity
EVM version
cancun
License
none
Compiler optimization
Enabled · 777 runs
Related activity

Transactions

Verified contract

Contract source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {Ownable} from '@solady/auth/Ownable.sol';
import {ReentrancyGuard} from '@solady/utils/ReentrancyGuard.sol';

import {AccessControl} from '@openzeppelin/contracts/access/AccessControl.sol';
import {ECDSA} from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol';
import {EIP712} from '@openzeppelin/contracts/utils/cryptography/EIP712.sol';
import {EnumerableSet} from '@openzeppelin/contracts/utils/structs/EnumerableSet.sol';

import {IMsgSender} from '@uniswap-periphery/interfaces/IMsgSender.sol';
import {BalanceDelta} from '@uniswap/v4-core/src/types/BalanceDelta.sol';
import {Currency} from '@uniswap/v4-core/src/types/Currency.sol';
import {PoolId} from '@uniswap/v4-core/src/types/PoolId.sol';
import {PoolKey} from '@uniswap/v4-core/src/types/PoolKey.sol';
import {SwapParams} from '@uniswap/v4-core/src/types/PoolOperation.sol';

import {IFeeCalculator} from '@flaunch-interfaces/IFeeCalculator.sol';
import {ISpendGatedSignerFeeCalculator} from '@flaunch-interfaces/ISpendGatedSignerFeeCalculator.sol';
import {ProtocolRoles} from '@flaunch/libraries/ProtocolRoles.sol';

/**
 * A {IFeeCalculator} implementation that gates swaps behind a signature issued by a trusted
 * off-chain signer, where each authorization carries an explicit maximum native (flETH) spend.
 *
 * This is a fork of {TrustedSignerFeeCalculator} with one structural change: caps are
 * denominated in native token INPUT rather than estimated memecoin output. The authorized
 * spend is a signed field (`maxSpendWei`), so the issuing backend controls exactly how much
 * ETH each authorization can deploy - the unit is stable regardless of how far the pool price
 * has moved. This supports flows where a user earns spend allowance progressively (e.g. a
 * game) and claims it against a floating price.
 *
 * The fee itself is left untouched; this contract only validates access and tracks spend,
 * returning the base fee unchanged from {determineSwapFee}.
 */
contract SpendGatedSignerFeeCalculator is IFeeCalculator, ISpendGatedSignerFeeCalculator, Ownable, ReentrancyGuard, AccessControl, EIP712 {
    using EnumerableSet for EnumerableSet.AddressSet;
    using ECDSA for bytes32;

    /// The EIP-712 type hash for a gated swap authorization
    bytes32 internal constant SPEND_AUTHORIZATION_TYPEHASH =
        keccak256('SpendAuthorization(address buyer,bytes32 poolId,uint256 deadline,uint256 maxSpendWei,uint256 nonce)');

    /**
     * Tags the long-form flaunch params, so the shape is read from an explicit marker rather than
     * inferred from the payload's length: the shape must announce itself.
     *
     * Length alone is not a discriminator: `feeCalculatorParams` is caller-controlled, and any
     * other encoding that happened to be the right number of words would be reinterpreted
     * field-for-field. A dynamic type in the signer's position decodes its ABI offset as the
     * signer, which installs a permanent per-pool signer nobody holds the key to.
     */
    bytes32 public constant GATE_PARAMS_TAG = keccak256('flaunch.spendGate.params');

    /**
     * The longest window an ENFORCING gate may be launched with, measured from its own launch.
     *
     * Neither the launcher nor the signer is vetted, and they are routinely the same party:
     * `flaunch()` is permissionless, `feeCalculatorParams` is caller-supplied, and a launch may
     * name ITSELF as both the round's signer and its settler. Because the gate enforces on sells
     * as well as buys, such a launcher would otherwise hold a permanent veto over every holder's
     * exit — issue buy authorizations, then simply stop signing, and the position is trapped for
     * as long as the key stays silent. Bounding the window makes the exit a property of the pool
     * instead: the gate expires on chain, needing no transaction from the launcher, the settler,
     * or this contract's owner.
     */
    uint public constant MAX_GATE_DURATION = 30 days;

    /// The set of protocol-wide signers that can authorize swaps for any gated pool
    EnumerableSet.AddressSet internal _trustedSigners;

    /// Tracks how much native token a wallet has spent per pool, used to enforce the wallet cap
    mapping(PoolId _poolId => mapping(address _wallet => uint _amount)) public walletSpentWei;

    /// Stores an optional per-pool signer that overrides the protocol-wide trusted signers
    mapping(PoolId _poolId => TrustedPoolKeySigner _signer) public trustedPoolKeySigner;

    /// Stores the spend gate configuration (enabled flag and wallet cap) for each pool
    mapping(PoolId _poolId => SpendGateSettings _settings) public spendGateSettings;

    /// Tracks consumed authorization digests to prevent the same authorization being replayed
    mapping(bytes32 _digest => bool _used) internal _usedSignatures;

    /// Routers whose reported `msgSender()` may stand in for `tx.origin` when binding a buyer.
    /// Only contracts this owner vouches for belong here: an arbitrary swap router could lie
    /// about its original caller and hijack another wallet's authorization.
    mapping(address _router => bool _approved) public approvedRouters;

    /// Pools whose gate params have been written, so a launch-time hook cannot be replayed
    mapping(PoolId _poolId => bool _configured) public gateConfigured;

    /// The native token used by the protocol, set once at deployment
    address public immutable nativeToken;

    /**
     * Sets the native token and grants the deployer admin control over the contract.
     *
     * @param _nativeToken The address of the protocol's native token
     */
    constructor(
        address _nativeToken
    ) EIP712('FlaunchSpendGate', '1') {
        // Store the native token used to identify the non-memecoin side of a pool
        nativeToken = _nativeToken;

        // Grant the deployer the admin role and contract ownership
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _initializeOwner(msg.sender);
    }

    /**
     * Returns the swap fee for a pool. This calculator does not modify the fee, so the base fee
     * is always returned unchanged.
     *
     * @param _baseFee The base fee passed in by the {PositionManager}
     *
     * @return swapFee_ The unmodified base fee
     */
    function determineSwapFee(
        PoolKey memory,
        SwapParams memory,
        uint24 _baseFee
    ) public pure returns (uint24 swapFee_) {
        // We never alter the fee; just echo back the base fee
        return _baseFee;
    }

    /**
     * Configures the spend gate settings for a pool. Callable by any holder of the
     * POSITION_MANAGER role - in a dispatcher deployment that role is granted to the
     * {FeeCalculatorDispatcher}, which forwards the sub-params it decoded at flaunch time.
     *
     * @param _poolId The ID of the pool being configured
     * @param _params Empty to leave the gate disabled; the 64-byte short form
     * `abi.encode(bool enabled, uint walletCapWei)`; or the 192-byte tagged form
     * `abi.encode(GATE_PARAMS_TAG, bool enabled, uint walletCapWei, address signer, address settler, uint endsAt)`.
     * Any other payload — including the retired 160-byte tagged form that predated `endsAt` —
     * reverts with {UnrecognisedGateParams}.
     *
     * An ENFORCING gate must carry an expiry no further out than {MAX_GATE_DURATION}, so only the
     * tagged form can enable one; the short form is left for launching ungated
     */
    function setFlaunchParams(
        PoolId _poolId,
        bytes calldata _params
    ) external override {
        // Only the PositionManager (or dispatcher acting for it) may set gate parameters
        if (!hasRole(ProtocolRoles.POSITION_MANAGER, msg.sender)) {
            revert CallerNotPositionManager();
        }

        /**
         * This is a launch-time hook and nothing else, so it runs at most once per pool.
         *
         * The pool id is an argument, not something this contract derives, and the role that may
         * call it is grantable. Without this guard a role holder could rewrite the gate of any
         * live pool: disable it outright, reset the wallet cap, or name itself settler and then
         * open trading. Pinning it to one write per pool keeps the blast radius of a role
         * compromise to pools that have not launched yet.
         */
        if (gateConfigured[_poolId]) {
            revert GateAlreadyConfigured(_poolId);
        }
        gateConfigured[_poolId] = true;

        // Default to a disabled configuration with no cap and no expiry
        bool enabled;
        uint walletCapWei;
        address signer;
        address settler;
        uint endsAt;

        /**
         * Decode the provided settings only if any params were supplied.
         *
         * Two shapes are accepted: the tagged long form and the two-word short form. The long
         * form additionally names the round's signer, its settler and an expiry, which is what
         * lets a launch install a working gate in a SINGLE transaction: the signer's only other
         * route in is a follow-up {setTrustedPoolKeySigner} call, and until that lands nobody
         * can produce a signature the pool accepts. A game round is created by the player who
         * launches the coin but ended by the server that ran it, so the settler gives the server
         * an authority of its own. The short form stays decodable so existing callers and the
         * test suite keep working unchanged.
         */
        if (_params.length == 192) {
            // The long form must announce itself with {GATE_PARAMS_TAG}. Anything else six words
            // long is some other encoding that would otherwise be misread field-for-field.
            (bytes32 tag, bool tagEnabled, uint tagCap, address tagSigner, address tagSettler, uint tagEndsAt) =
                abi.decode(_params, (bytes32, bool, uint, address, address, uint));

            if (tag != GATE_PARAMS_TAG) {
                revert UnrecognisedGateParams(_params.length);
            }

            (enabled, walletCapWei, signer, settler, endsAt) = (tagEnabled, tagCap, tagSigner, tagSettler, tagEndsAt);
        } else if (_params.length == 64) {
            // Short form: exactly two words, so it cannot be confused with anything longer
            (enabled, walletCapWei) = abi.decode(_params, (bool, uint));
        } else if (_params.length != 0) {
            revert UnrecognisedGateParams(_params.length);
        }

        /**
         * A gated launch that names a signer must also name someone who can retire it.
         *
         * The signer IS the gate, and since the creator lost every authority over it (see
         * {setTrustedPoolKeySigner}) the settler is the only party in the launch who can lift it.
         * Naming a signer with a zero settler therefore mints a pool nobody involved can open:
         * every buy needs a signature from the round's key, and the only remaining off-switch is
         * this contract's owner — a protocol multisig that has no business sitting in the critical
         * path of an individual round, and cannot be reached on a round's timescale.
         *
         * `endsAt` is deliberately NOT accepted in its place. It bounds how long a pool stays gated
         * and it is the real guarantee for a server that never comes back, but it is whatever the
         * launch asked for (a decade is a valid value) and it cannot end a round EARLY, which is
         * precisely the case an off-switch exists for. Refusing the launch is also the cheaper
         * failure by a wide margin: one reverted flaunch, rather than an unsettleable coin that is
         * only discovered when a round tries to settle and cannot.
         *
         * A launch with no per-pool signer is untouched by this: it enforces against the
         * protocol-wide trusted signer set, which the owner administers anyway.
         */
        if (enabled && signer != address(0) && settler == address(0)) {
            revert SettlerRequired(_poolId);
        }

        /**
         * An enforcing gate must expire, and must expire soon.
         *
         * Everything above bounds who may LIFT a gate; this bounds how long one may exist at all,
         * which is the only protection that survives a launcher who never intends to lift it (see
         * {MAX_GATE_DURATION}). Checked after {SettlerRequired} deliberately: a launch that names
         * a signer with nobody to retire it is the more fundamental error and should say so.
         *
         * An `endsAt` already in the past stays legal — the launch picks its own window, and a
         * gate born expired simply never enforces, so the pool launches open. What is refused is a
         * window that never closes (`0`) and one that closes too far out to be a round.
         *
         * Only enforcing gates are constrained. A disabled gate has nothing to expire, which is
         * what keeps the two-word short form — unable to express an expiry at all — decodable for
         * the callers that use it to launch ungated.
         */
        if (enabled) {
            if (endsAt == 0) {
                revert GateExpiryRequired(_poolId);
            }

            uint latestPermitted = block.timestamp + MAX_GATE_DURATION;
            if (endsAt > latestPermitted) {
                revert GateExpiryTooDistant(endsAt, latestPermitted);
            }
        }

        // Store the resolved spend gate settings against the pool
        spendGateSettings[_poolId] = SpendGateSettings({
            enabled: enabled,
            walletCapWei: walletCapWei,
            settler: settler,
            endsAt: endsAt
        });

        // A signer named at flaunch is installed immediately, so the gate is live the moment the
        // pool exists. The settler zeroing it later is what opens trading early.
        if (signer != address(0)) {
            trustedPoolKeySigner[_poolId] = TrustedPoolKeySigner({signer: signer, enabled: true});
            emit PoolKeySignerUpdated(_poolId, signer);
        }

        // Notify listeners of the updated configuration
        emit PoolGateUpdated(_poolId, spendGateSettings[_poolId]);
    }

    /**
     * Validates a swap against the pool's spend gate rules. Called after a swap, this enforces
     * signature authorization, replay protection and the signed spend cap.
     *
     * @dev Authority comes from the signed message: the authorized buyer and the maximum native
     * spend are explicit, signed fields. The buyer binding is anti-front-running: a copied signed
     * message cannot be submitted by anyone other than the buyer it was issued to. The binding is
     * satisfied either by `tx.origin` (an EOA submitting its own transaction) or by the
     * `msgSender()` reported by an approved router, which admits smart-contract wallets and
     * account-abstraction flows (7702 relays, 4337 bundlers) whose `tx.origin` is a relayer.
     *
     * @param _sender The address that called `PoolManager.swap`, forwarded verbatim by the hook
     * and dispatcher; consulted only for the approved-router buyer binding
     * @param _poolKey The key for the pool being swapped against
     * @param _params The swap parameters, used to identify the native input for exact-input swaps
     * @param _delta The swap's balance delta, used to measure the actual native spend
     * @param _hookData Arbitrary swap data carrying the signed authorization message
     */
    function trackSwap(
        address _sender,
        PoolKey calldata _poolKey,
        SwapParams calldata _params,
        BalanceDelta _delta,
        bytes calldata _hookData
    ) public nonReentrant {
        // Only the PositionManager (or dispatcher acting for it) is permitted to report swaps
        if (!hasRole(ProtocolRoles.POSITION_MANAGER, msg.sender)) {
            revert CallerNotPositionManager();
        }

        // If the spend gate is not enabled for this pool, there is nothing to enforce
        PoolId poolId = _poolKey.toId();
        SpendGateSettings memory settings = spendGateSettings[poolId];
        if (!settings.enabled) {
            return;
        }

        // Past `endsAt` the gate has expired on its own: no signature demanded, no spend
        // tracked — the pool is an ordinary pool. This is what makes "freely tradeable after
        // the window" a property of the pool rather than a promise kept by whoever holds the
        // settler key; zeroing the signer below remains the EARLY off-switch.
        //
        // An enforcing gate can no longer be configured with a zero `endsAt` (see
        // {setFlaunchParams}), so that leg only ever spares a pool written before the bound
        // existed; it is kept so this read never depends on which release wrote the settings.
        if (settings.endsAt != 0 && block.timestamp > settings.endsAt) {
            return;
        }

        // If the pool opted into a per-pool signer but it is unset, skip enforcement. This doubles
        // as the gate's off-switch: the settler zeroes the signer to open trading.
        TrustedPoolKeySigner memory poolSigner = trustedPoolKeySigner[poolId];
        if (poolSigner.enabled && poolSigner.signer == address(0)) {
            return;
        }

        // Validate the signed message the swap carries and recover the authorized buyer and
        // spend allowance from it. Both are explicit, signed fields, so authority is derived
        // solely from the signature and can never be spoofed by a lying router.
        (SignedMessage memory signedMessage, bytes32 messageHash) = _validateSignature(poolId, _hookData);

        // Bind the authorization to its submitter. The signed message is public calldata, so a
        // front-runner could otherwise copy a victim's message to receive the tokens, consume the
        // victim's allowance and burn the signature. An EOA satisfies the binding directly via
        // `tx.origin`; a smart-account submission (7702 relay, 4337 bundler) satisfies it through
        // the `msgSender()` reported by an approved router — approval matters because any other
        // contract could simply lie about its original caller. A front-runner replaying the
        // message through an approved router is still refused: the router honestly reports the
        // front-runner, not the buyer.
        if (signedMessage.buyer != tx.origin) {
            if (!approvedRouters[_sender]) {
                revert BuyerNotSubmitter(signedMessage.buyer, tx.origin, _sender);
            }

            try IMsgSender(_sender).msgSender() returns (address reported) {
                if (reported != signedMessage.buyer) {
                    revert BuyerNotSubmitter(signedMessage.buyer, tx.origin, _sender);
                }
            } catch {
                revert BuyerNotSubmitter(signedMessage.buyer, tx.origin, _sender);
            }
        }

        // `messageHash` is already the final EIP-712 digest (see {_validateSignature}), so it is
        // used directly rather than re-wrapped with an EIP-191 prefix.

        // Reject signatures that have already been used to prevent replays
        if (_usedSignatures[messageHash]) {
            revert SignatureAlreadyUsed();
        }

        // Recover the signer from the signature and check it against the expected authority
        (address recoveredSigner,,) = messageHash.tryRecover(signedMessage.signature);
        if (poolSigner.enabled) {
            // A per-pool signer must match exactly
            if (recoveredSigner != poolSigner.signer) {
                revert InvalidSigner(recoveredSigner);
            }
        } else if (!isTrustedSigner(recoveredSigner)) {
            // Otherwise fall back to the protocol-wide trusted signer set
            revert InvalidSigner(recoveredSigner);
        }

        /**
         * Exact-output swaps cannot be measured here, so they are refused outright.
         *
         * The delta `afterSwap` receives is already net of any `beforeSwapDelta`, and the
         * {InternalSwapPool} fills buys out of accrued memecoin inventory through exactly that
         * mechanism. Native taken for such a fill never appears in this delta, and the
         * `amountSpecified` fallback below only covers exact input — so an exact-output buy the
         * inventory could cover outright measured as ZERO spend. Zero passes any `maxSpendWei`
         * and never reaches `walletSpentWei`, so neither cap could see it and the buy was
         * repeatable with every fresh authorization.
         *
         * Refusing exact output is the safe direction: the gate exists to bound native input, and
         * every buy path the round actually uses is exact-input native.
         */
        if (_params.amountSpecified > 0) {
            revert ExactOutputNotSupported();
        }

        // Measure the actual native token spent by the swap and enforce the signed cap
        uint ethSpent = _nativeSpent(_poolKey, _params, _delta);
        if (ethSpent > signedMessage.maxSpendWei) {
            revert SpendCapExceeded(ethSpent, signedMessage.maxSpendWei);
        }

        /**
         * Record the spend against the signed buyer and enforce the optional wallet backstop.
         *
         * Unconditional, including when the measure is zero. A zero here is a sell (the swapper
         * receives native), which legitimately consumes an authorization without spending, and
         * emitting it keeps the on-chain record complete for off-chain reconciliation — the
         * previous `if (ethSpent != 0)` guard meant a consumed signature could leave no trace.
         */
        uint walletSpent = walletSpentWei[poolId][signedMessage.buyer] + ethSpent;
        if (settings.walletCapWei != 0 && walletSpent > settings.walletCapWei) {
            revert WalletCapExceeded(walletSpent, settings.walletCapWei);
        }

        walletSpentWei[poolId][signedMessage.buyer] = walletSpent;
        emit SpendRecorded(poolId, signedMessage.buyer, ethSpent);

        // Mark the signature as consumed
        _usedSignatures[messageHash] = true;
    }

    /**
     * Adds a protocol-wide trusted signer that can authorize swaps for any gated pool.
     *
     * @param _signer The address of the signer to trust
     */
    function addTrustedSigner(
        address _signer
    ) external onlyOwner {
        // The zero address can never be a valid signer
        if (_signer == address(0)) {
            revert InvalidSigner(_signer);
        }

        // Add the signer, reverting if it was already present
        if (!_trustedSigners.add(_signer)) {
            revert SignerAlreadyAdded(_signer);
        }

        // Notify listeners that the signer is now trusted
        emit TrustedSignerUpdated(_signer, true);
    }

    /**
     * Removes a protocol-wide trusted signer.
     *
     * @param _signer The address of the signer to remove
     */
    function removeTrustedSigner(
        address _signer
    ) external onlyOwner {
        // Remove the signer, reverting if it was not present
        if (!_trustedSigners.remove(_signer)) {
            revert SignerDoesNotExist(_signer);
        }

        // Notify listeners that the signer is no longer trusted
        emit TrustedSignerUpdated(_signer, false);
    }

    /**
     * Approves or retires a router whose `msgSender()` report may satisfy the buyer binding in
     * {trackSwap}. Approval is a statement that the router honestly reports the account that
     * initiated the swap (e.g. the canonical Universal Router, flaunch's own {PoolSwap}).
     *
     * @param _router The router contract to approve or retire
     * @param _approved Whether the router's `msgSender()` report is to be trusted
     */
    function setApprovedRouter(
        address _router,
        bool _approved
    ) external onlyOwner {
        // The zero address can never be a router; refusing it here keeps the mapping's zero
        // entry meaning "not approved" even if a hook ever reports a zero sender
        if (_router == address(0)) {
            revert InvalidRouter(_router);
        }

        approvedRouters[_router] = _approved;
        emit ApprovedRouterUpdated(_router, _approved);
    }

    /**
     * Nominates a dedicated signer for a pool, overriding the protocol-wide trusted signers for
     * that pool. Setting the signer to the zero address disables enforcement entirely, opening
     * the pool to ungated trading.
     *
     * Callable by the settler named in the pool's flaunch params, or by the contract owner.
     *
     * @param _poolKey The key for the pool being configured
     * @param _signer The address that will authorize swaps for this pool
     */
    function setTrustedPoolKeySigner(
        PoolKey calldata _poolKey,
        address _signer
    ) external nonReentrant {
        /**
         * One authority over a pool's signer, plus a break-glass.
         *
         * The settler named at flaunch may install, rotate and retire it: the round's server has to
         * open trading at the end of a round without the creator present, and to replace a key it
         * believes is compromised. The owner may too, as a break-glass for a pool whose settler is
         * unreachable.
         *
         * The memecoin's creator has NO authority here — not even to retire. Retiring used to stay
         * available to them on the reading that opening your own coin to the market can only be
         * generous. It is not, because in Game Mode the launching PLAYER is the memecoin's creator
         * (that is what makes the round theirs): zeroing the signer is how that player buys their
         * own fair launch ungated, for as much as they like, while every other player is still
         * earning allowance a point at a time. Nothing downstream catches it either — the watcher
         * polls `Swap`, and an ungated swap is indistinguishable from an authorized one. Nor could
         * the power ever be pinned to the launching player, because `IMemecoin.creator()` reads the
         * Flaunch ERC721 owner live: the role transfers with a secondary sale of the NFT, so it was
         * sellable mid-round to someone the round never met.
         *
         * What the creator keeps is the guarantee that does not require trusting anyone: `endsAt`
         * expires the gate on chain (see {trackSwap}), so the coin reaches the open market on time
         * with no settle transaction from anyone. And a gated launch cannot name a signer without
         * naming a settler to lift it (see {setFlaunchParams}), so dropping the creator here never
         * leaves a pool with nobody able to open it.
         */
        PoolId poolId = _poolKey.toId();
        address settler = spendGateSettings[poolId].settler;
        if (msg.sender != owner() && settler != msg.sender) {
            revert NotSettler(msg.sender, settler);
        }

        // Store the per-pool signer and flag it as enabled
        trustedPoolKeySigner[poolId] = TrustedPoolKeySigner({signer: _signer, enabled: true});

        // Notify listeners of the new per-pool signer
        emit PoolKeySignerUpdated(poolId, _signer);
    }

    /**
     * Checks whether an address is a protocol-wide trusted signer.
     *
     * @param _signer The address to check
     *
     * @return valid_ True if the address is a trusted signer
     */
    function isTrustedSigner(
        address _signer
    ) public view returns (bool valid_) {
        valid_ = _trustedSigners.contains(_signer);
    }

    /**
     * Measures the native token spent by a swap.
     *
     * The primary source is the swap's {BalanceDelta}: a negative native-side amount is what the
     * swapper actually paid. As a defensive backstop for exact-input native swaps, the specified
     * input amount is also considered and the larger of the two is enforced - hook-side fills
     * that adjust the visible delta can only make enforcement stricter, never looser.
     *
     * @param _poolKey The key for the pool
     * @param _params The swap parameters
     * @param _delta The swap's balance delta
     *
     * @return ethSpent_ The native amount spent; zero for sells (native received)
     */
    function _nativeSpent(
        PoolKey calldata _poolKey,
        SwapParams calldata _params,
        BalanceDelta _delta
    ) internal view returns (uint ethSpent_) {
        bool nativeIsZero = nativeToken == Currency.unwrap(_poolKey.currency0);

        // A negative native-side delta is the amount the swapper paid
        int128 nativeDelta = nativeIsZero ? _delta.amount0() : _delta.amount1();
        if (nativeDelta < 0) {
            ethSpent_ = uint(uint128(-nativeDelta));
        }

        // For an exact-input swap where the native token is the input, the specified amount is
        // the swapper's full committed input; enforce whichever measure is larger
        bool nativeIsInput = _params.zeroForOne == nativeIsZero;
        if (nativeIsInput && _params.amountSpecified < 0) {
            uint specified = uint(-_params.amountSpecified);
            if (specified > ethSpent_) {
                ethSpent_ = specified;
            }
        }
    }

    /**
     * Decodes and validates the signed authorization message carried in the swap's hook data,
     * checking the deadline and target pool before returning the message along with the hash
     * needed to recover the signer.
     *
     * @param _poolId The ID of the pool being swapped against
     * @param _hookData The hook data containing the encoded {SignedMessage}
     *
     * @return signedMessage_ The decoded authorization message
     * @return messageHash_ The hash that the signature is expected to have signed
     */
    function _validateSignature(
        PoolId _poolId,
        bytes calldata _hookData
    ) internal view returns (SignedMessage memory signedMessage_, bytes32 messageHash_) {
        // Extract the signed message from the hook data, skipping the leading referrer field
        (, signedMessage_) = abi.decode(_hookData, (address, SignedMessage));

        // Reject the authorization if it has expired
        if (block.timestamp > signedMessage_.deadline) {
            revert DeadlineExpired(signedMessage_.deadline);
        }

        // Ensure the message was signed for the pool actually being swapped
        if (signedMessage_.poolId != PoolId.unwrap(_poolId)) {
            revert InvalidPoolKey();
        }

        // Reconstruct the signed digest from the buyer, pool, deadline, spend cap and nonce.
        // Binding the buyer and cap into the digest is what ties the authorization to a wallet and
        // a budget; the nonce guarantees two otherwise-identical claims (same buyer, pool,
        // deadline and spend) still produce distinct digests, so a retried buy is not mistaken
        // for a replay of the first.
        messageHash_ = hashSpendAuthorization(
            signedMessage_.buyer,
            signedMessage_.poolId,
            signedMessage_.deadline,
            signedMessage_.maxSpendWei,
            signedMessage_.nonce
        );
    }

    /**
     * The exact EIP-712 digest an off-chain signer must sign to authorize a gated swap.
     *
     * @dev Exposed so the backend signer (and integration tests) can derive the digest from this
     * contract rather than reimplementing the domain and struct hashing, which is what makes a
     * domain mismatch a silent `InvalidSigner` at swap time instead of a loud failure at signing
     * time. The digest is chain- and contract-specific by construction, which is the point: the
     * previous `abi.encodePacked` preimage committed to neither, so an authorization issued for
     * one deployment was valid on any other deployment sharing a signing key — and because
     * Flaunch deploys deterministically across chains, the same pool id genuinely does recur.
     *
     * @param _buyer The wallet being authorized
     * @param _poolId The pool the authorization is valid for
     * @param _deadline The timestamp the authorization expires at
     * @param _maxSpendWei The maximum native spend this authorization permits
     * @param _nonce Distinguishes two otherwise-identical authorizations
     *
     * @return digest_ The digest to sign
     */
    function hashSpendAuthorization(
        address _buyer,
        bytes32 _poolId,
        uint _deadline,
        uint _maxSpendWei,
        uint _nonce
    ) public view returns (bytes32 digest_) {
        digest_ = _hashTypedDataV4(
            keccak256(abi.encode(SPEND_AUTHORIZATION_TYPEHASH, _buyer, _poolId, _deadline, _maxSpendWei, _nonce))
        );
    }

    /**
     * Whether a spend authorization digest has already been consumed.
     *
     * @param _digest The digest returned by {hashSpendAuthorization}
     *
     * @return used_ True if the authorization can no longer be used
     */
    function isAuthorizationUsed(
        bytes32 _digest
    ) public view returns (bool used_) {
        used_ = _usedSignatures[_digest];
    }
}