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];
}
}