Hooks are deeply integrated into the trading lifecycle and cover open/close/cancel/liquidation/close-expiry paths.
ISymmioHook Interface
Every hook contract must implement the ISymmioHook interface:
// contracts/core/interfaces/ISymmioHook.sol
interface ISymmioHook {
enum TradingFeeType {
OPEN,
CLOSE
}
function onOpenPosition(
uint256 quoteId,
uint256 filledAmount,
uint256 openedPrice,
address partyA,
address partyB
) external;
function onClosePosition(
uint256 quoteId,
uint256 filledAmount,
uint256 closedPrice,
address partyA,
address partyB
) external;
function onCancelQuote(
uint256 quoteId,
address partyA,
address partyB
) external;
function onCloseExpired(
uint256 quoteId,
address partyA,
address partyB
) external;
function onFeeCharged(
uint256 quoteId,
uint256 amount,
address partyA,
address partyB,
uint256 symbolId,
address affiliate,
TradingFeeType feeType
) external;
}
onOpenPosition — Called when PartyB fills a quote and opens a position.
filledAmount is the quantity filled (may be partial for LIMIT orders). openedPrice is the execution
price.
onClosePosition — Called when a position is closed, whether through a normal close request fill
or through liquidation. filledAmount is the closed quantity and closedPrice is the
execution/liquidation price.
onCancelQuote — Called when a quote is cancelled or expires before being filled. This covers
direct cancellation by PartyA, accepted cancellation by PartyB, expiration, and cancellation during ClearingHouse liquidation.
onCloseExpired — Called when a close request is removed and the quote returns to
OPENED (expiration of CLOSE_PENDING/CANCEL_CLOSE_PENDING, or force-cancel of
CANCEL_CLOSE_PENDING).
onFeeCharged — Called alongside onOpenPosition and onClosePosition to
report the exact fee amount charged. The TradingFeeType enum distinguishes between open fees and close fees. The
affiliate address and symbolId are included so the hook can attribute fees to the correct campaign.
Hook Registration
Hooks are stored in AffiliateStorage as a simple mapping from affiliate address to hook contract address:
// contracts/core/storages/AffiliateStorage.sol
/// @notice Hook contracts called on protocol events per affiliate
/// @dev Called on onOpenPosition, onClosePosition, onCancelQuote,
/// onCloseExpired, and onFeeCharged events.
/// address(0) key is the system-wide hook. Enables custom integrations.
mapping(address => address) affiliateHooks;
Per-Affiliate Hooks
Each affiliate can have one hook contract. When a quote is created through a specific affiliate, all lifecycle events for that
quote call the affiliate's hook. Registration is done through ControlFacet by an address holding
INTEGRATION_ADMIN_ROLE:
// contracts/core/facets/Control/ControlFacet.sol
function registerHook(
address affiliate,
address hook
) external onlyRole(LibAccessibility.INTEGRATION_ADMIN_ROLE) {
AffiliateStorage.layout().affiliateHooks[affiliate] = hook;
emit RegisterHook(affiliate, hook);
}
System-Wide Hook (address(0))
The address(0) key acts as a system-wide hook. When set, this hook is called for every quote
regardless of its affiliate. Both hooks fire on every event: first the affiliate-specific hook, then the system hook.
address affiliateHook = AffiliateStorage.layout().affiliateHooks[quote.affiliate];
address systemHook = AffiliateStorage.layout().affiliateHooks[address(0)];
This dual-hook pattern means a single trading event can trigger up to 2 hook calls per callback type (e.g., opening a position
fires onOpenPosition on both the affiliate hook and the system hook, plus onFeeCharged on both — 4
external calls total).
Querying Hooks
The ViewFacet exposes a getter:
function getAffiliateHook(address affiliate) external view returns (address hook) {
return AffiliateStorage.layout().affiliateHooks[affiliate];
}
Where Hooks Fire in the Lifecycle
The following table maps every hook call site to the contract function that triggers it:
| Event | Callback(s) | Source File | Trigger |
|---|---|---|---|
| Position opened | onOpenPosition + onFeeCharged(OPEN) |
LibPartyBPositionsActions.openPosition |
PartyB fills a quote |
| Position closed | onClosePosition + onFeeCharged(CLOSE) |
LibQuoteClose.closeQuote |
PartyB fills a close request |
| Quote cancelled (PartyA) | onCancelQuote |
PartyAFacetImpl.requestToCancelQuote |
PartyA cancels a PENDING quote |
| Quote cancelled (PartyB) | onCancelQuote |
PartyBQuoteActionsFacetImpl.acceptCancelRequest |
PartyB accepts a cancel request |
| Quote force-cancelled | onCancelQuote |
ForceActionsFacetImpl.forceCancelQuote |
PartyA force-cancels a previously LOCKED quote after cooldown |
| Quote expired | onCancelQuote |
LibQuoteClose.expireQuote |
Quote deadline passes |
| Pending liquidated in PartyA liquidation | onCancelQuote |
PartyALiquidationFacetImpl.liquidatePendingPositionsPartyA |
PartyA liquidation on pending quotes |
| Pending liquidated in PartyB liquidation | onCancelQuote |
PartyBLiquidationFacetImpl.liquidatePartyB, LibForceActions.liquidatePartyB |
PartyB liquidation path |
| PartyB liquidation | onClosePosition |
PartyBLiquidationFacetImpl.liquidatePositionsPartyB |
Isolated-mode PartyB liquidation |
| PartyA liquidation | onClosePosition |
PartyALiquidationFacetImpl.liquidatePositionsPartyA |
PartyA liquidation |
| Close request expired/force-cancelled | onCloseExpired |
LibQuoteClose.expireQuote, ForceActionsFacetImpl.forceCancelCloseRequest |
Close request is removed and quote reopens |
| ClearingHouse pending cancel | onCancelQuote |
ClearingHouseFacetImpl._callCancelQuoteHooksAndUpdateStatus |
Cross-PartyB or PartyA takeover liquidation (pending quotes) |
| ClearingHouse position liquidation | onClosePosition |
ClearingHouseFacetImpl.liquidatePositionsForClearingHouse |
Cross-PartyB or PartyA takeover liquidation (open positions) |
Open Position Flow
In LibPartyBPositionsActions.openPosition, after the position is fully created and all balances updated, four
hook calls fire:
uint256 openFee = (filledAmount * quote.openedPrice * quote.tradingFee) / 1e36;
// 1. Affiliate hook: onOpenPosition
LibHook.safeCall(
affiliateHook,
abi.encodeCall(ISymmioHook.onOpenPosition,
(quoteId, filledAmount, openedPrice, quote.partyA, quote.partyB)),
quoteId
);
// 2. Affiliate hook: onFeeCharged (OPEN)
LibHook.safeCall(
affiliateHook,
abi.encodeCall(ISymmioHook.onFeeCharged,
(quoteId, openFee, quote.partyA, quote.partyB,
quote.symbolId, quote.affiliate, ISymmioHook.TradingFeeType.OPEN)),
quoteId
);
// 3. System hook: onOpenPosition
LibHook.safeCall(systemHook, ...);
// 4. System hook: onFeeCharged (OPEN)
LibHook.safeCall(systemHook, ...);
Close Position Flow
In LibQuoteClose.closeQuote, after PnL settlement, fee deduction, and status update, the same four-call pattern
fires with onClosePosition and onFeeCharged(CLOSE).
Cancel / Expire Flow
Cancel and expire paths use the convenience function LibHook.callCancelQuoteHooks, which calls
onCancelQuote on both hooks:
function callCancelQuoteHooks(
uint256 quoteId,
address partyA,
address partyB,
address affiliate
) internal {
address affiliateHook = AffiliateStorage.layout().affiliateHooks[affiliate];
address systemHook = AffiliateStorage.layout().affiliateHooks[address(0)];
safeCall(affiliateHook, abi.encodeCall(ISymmioHook.onCancelQuote, (quoteId, partyA, partyB)), quoteId);
safeCall(systemHook, abi.encodeCall(ISymmioHook.onCancelQuote, (quoteId, partyA, partyB)), quoteId);
}
Liquidation Flows
During liquidations (PartyA, isolated PartyB, and ClearingHouse), hooks fire via
LibHook.callClosePositionHooks for position closures and _callCancelQuoteHooksAndUpdateStatus for
pending quote cancellations. The liquidation price is passed as closedPrice, so hook contracts can distinguish
normal closes from liquidations by comparing against the original openedPrice or by checking quote status.
LibHook.safeCall — Security Model
LibHook.safeCall is the single entry point for all hook invocations. Its implementation addresses the core
security concern:
a hook contract must not be able to impersonate the user whose transaction triggered the hook.
// contracts/core/libraries/LibHook.sol
function safeCall(address hook, bytes memory data, uint256 quoteId) internal {
if (hook == address(0)) return;
// Save and clear signer before external call
address previousSigner = GlobalAppStorage.layout().signer;
GlobalAppStorage.layout().signer = address(0);
(bool success, bytes memory reason) = hook.call(data);
// Revert on hook failures to avoid inconsistency
if (!success) {
revert HookReverted(hook, bytes4(data), quoteId, reason);
}
// Restore signer after hook call
GlobalAppStorage.layout().signer = previousSigner;
}
Why the Signer Is Cleared
SYMMIO uses a meta-transaction pattern where GlobalAppStorage.signer identifies the actual user behind a proxied
call. If this signer were still set when the hook executes, a malicious hook could call back into the diamond and execute
operations as that user (e.g., deallocate their funds). By clearing the signer to address(0) before the external
call, any re-entrant call into the diamond will fail authorization checks because LibSigner.getSigner() returns
address(0) — an address that holds no roles and owns no positions.
The MaliciousHook test contract in the repository demonstrates this protection:
// contracts/core/test/MaliciousHook.sol
// Try to call deallocate as the user (this should fail because signer is cleared)
try ISymmioCore(symmioCore).deallocate(deallocateAmount) {
reentrySucceeded = true;
} catch (bytes memory error) {
reentrySucceeded = false; // Expected: always fails
reentryError = error;
}
Revert Behavior
Hook failures cause the entire transaction to revert. This is an intentional design decision, documented in the code:
// NOTE: We intentionally revert on hook failures for now to avoid inconsistency
if (!success) {
revert HookReverted(hook, bytes4(data), quoteId, reason);
}
The HookReverted error includes the hook address, the function selector that failed, the quote ID, and the revert
reason bytes, enabling precise diagnosis.
This means a broken or malicious hook can block the operation it is attached to. A reverting onOpenPosition hook
prevents positions from being opened for all quotes associated with that affiliate. A reverting
onClosePosition hook prevents positions from being closed or liquidated. This is a trust tradeoff: the protocol
trusts that registered hooks are well-behaved, and the INTEGRATION_ADMIN_ROLE that registers hooks is responsible
for vetting them.
Security Considerations
Hook revert blocks operations. Because safeCall reverts the parent transaction on hook failure,
a faulty hook can effectively freeze trading for an affiliate's users. The INTEGRATION_ADMIN_ROLE holder must vet
hooks before registration and can remove a broken hook by calling registerHook(affiliate, address(0)).
Signer cleared before hook. The GlobalAppStorage.signer is set to address(0) before
the hook call and restored afterward. This prevents the hook from acting on behalf of the user. Any call back into the diamond
during hook execution will see no authenticated signer.
No gas limit on hook calls. The hook is called with all remaining gas. A hook that consumes excessive gas will cause the parent transaction to run out of gas. Hook implementers should keep callbacks cheap and avoid unbounded loops.
Hook receives raw addresses, not signatures. Hooks receive partyA and partyB as
plain addresses. They do not receive Muon signatures or any cryptographic proof. The hook trusts that it is being called by
the SYMMIO diamond, which should be verified via msg.sender checks in the hook contract (e.g., an
onlySymmio modifier).
Dual invocation. Both the affiliate-specific hook and the system-wide hook fire for every event. If either reverts, the transaction reverts. This means registering a system-wide hook introduces a global dependency: if it breaks, all trading halts.
Account Layer Integration
The account layer implements ISymmioHook through SymmioHookFacet, which is registered as a hook on
the core diamond. This demonstrates a production use case where the hook maintains virtual account state:
// contracts/accountLayer/facets/SymmioHook/SymmioHookFacet.sol
function onClosePosition(
uint256 quoteId, uint256, uint256, address partyA, address
) external onlySymmio nonReentrant whenNotPaused {
_removeQuoteFromAccount(quoteId, partyA);
}
function onCancelQuote(
uint256 quoteId, address partyA, address
) external onlySymmio whenNotPaused {
_removeQuoteFromAccount(quoteId, partyA);
}
When all quotes for a virtual account are closed or cancelled, the hook automatically deletes the virtual account and returns
remaining funds to the parent account. The onOpenPosition, onCloseExpired, and
onFeeCharged callbacks are no-ops in this implementation but are still defined to satisfy the interface and
prevent reverts.
Use Cases
Affiliate campaign tracking. A hook can record every position opened through an affiliate, tracking volume,
fee revenue, and user activity on-chain. The onFeeCharged callback provides the exact fee amount and the
affiliate address, enabling transparent revenue attribution.
Cashback and rebates. A hook can calculate and distribute cashback to traders based on their trading volume
or fee spend. The amount parameter in onFeeCharged gives the exact fee charged, from which a
percentage can be rebated.
Loyalty points and NFT minting. A hook can mint ERC-20 loyalty tokens or ERC-721 NFTs when traders reach
volume milestones. The filledAmount * openedPrice notional value is available from onOpenPosition.
On-chain analytics. A hook can maintain aggregate statistics — total volume per symbol, average position size, number of active traders — that other contracts or off-chain systems can query.
Virtual account lifecycle. As demonstrated by the account layer, a hook can manage derived state that depends on the quote lifecycle, automatically cleaning up resources when positions close.
Related Files
| File | Role |
|---|---|
contracts/core/interfaces/ISymmioHook.sol |
Interface definition |
contracts/core/libraries/LibHook.sol |
safeCall, callCancelQuoteHooks, callCloseExpiredHooks,
callClosePositionHooks
|
contracts/core/storages/AffiliateStorage.sol |
affiliateHooks mapping |
contracts/core/facets/Control/ControlFacet.sol |
registerHook (registration) |
contracts/core/facets/ViewFacet/ViewFacet.sol |
getAffiliateHook (query) |
contracts/core/libraries/LibPartyBPositionsActions.sol |
Hook calls on position open |
contracts/core/libraries/LibQuoteClose.sol |
Hook calls on position close and quote expiry |
contracts/core/facets/PartyA/PartyAFacetImpl.sol |
Hook calls on quote cancellation |
contracts/core/facets/PartyBQuoteActions/PartyBQuoteActionsFacetImpl.sol |
Hook calls on accepted cancel |
contracts/core/facets/PartyALiquidation/PartyALiquidationFacetImpl.sol |
Hook calls during PartyA liquidation |
contracts/core/facets/PartyBLiquidation/PartyBLiquidationFacetImpl.sol |
Hook calls during PartyB liquidation |
contracts/core/facets/ClearingHouse/ClearingHouseFacetImpl.sol |
Hook calls during ClearingHouse liquidation |
contracts/core/test/MockHook.sol |
Test hook with configurable revert behavior |
contracts/core/test/MaliciousHook.sol |
Test hook verifying re-entry protection |
contracts/accountLayer/facets/SymmioHook/SymmioHookFacet.sol |
Production hook for virtual account lifecycle |