Coil¶
Batch-auction intent DEX. Solvers settle signed EIP-712 intents at one uniform clearing price per batch.
Source: Coil-DEX/src/Coil.vy (Vyper 0.4.x)
Concept guides: Coil overview · Batch auction · Intents · Solver
Downstream: CoilFeeRouter (fee_recipient)
Implementation overview¶
- Off-chain intents — users sign EIP-712
Intent(sell/buy tokens, amounts, deadline, nonce). - Batch settlement — authorized solver calls
execute_batch; all intents clear at same price. - Partial fills —
filled_amount[intent_hash]tracks cumulative sell-side fill. - Signatures — EOA via
ecrecover; smart wallets via EIP-1271. - Fees —
fee_bps(max 1%) accrued per fill;withdraw_accrued_feestofee_recipient.
flowchart LR
User[User signs intent] --> Solver[Authorized solver]
Solver -->|execute_batch| Coil[Coil.vy]
Coil -->|protocol fees| CFR[CoilFeeRouter]
Immutables¶
| Name | Role |
|---|---|
INITIAL_CHAIN_ID |
Deploy chain for EIP-712 replay protection |
INITIAL_DOMAIN_SEPARATOR |
Cached domain separator (rebuilt on fork) |
Constants¶
| Name | Value | Role |
|---|---|---|
MAX_BATCH_SIZE |
50 | Intents per batch |
MAX_INTENT_LIFETIME |
7 days | Max deadline - now |
MAX_FEE_BPS |
100 | 1% fee cap |
MIN_ORDER_DIVISOR |
10_000 | Dust floor = 0.0001 whole token |
Events¶
| Event | When |
|---|---|
BatchSettled |
Solver clears batch at uniform price |
IntentFilled |
Per-intent fill in batch |
IntentCancelled |
Owner cancels unfilled intent |
SolverUpdated / TokenAllowed / Paused / FeeUpdated / FeeRecipientUpdated |
Admin |
ProtocolFeesAccrued / TokenWithdrawn |
Fee accounting |
OwnershipTransferStarted / OwnershipTransferred |
Two-step ownership |
Admin¶
transfer_ownership¶
Coil.transfer_ownership(new_owner)
Begin two-step ownership transfer. Pending owner must call accept_ownership.
| Param | Type | Description |
|---|---|---|
new_owner |
address |
Address that will become owner after acceptance |
Returns / state: Sets pending_owner = new_owner.
Access: Current owner only.
Events: OwnershipTransferStarted(current_owner, pending_owner).
Source (Coil-DEX/src/Coil.vy:231-236):
@external
def transfer_ownership(new_owner: address):
assert msg.sender == self.owner, "Coil: not owner"
self.pending_owner = new_owner
log OwnershipTransferStarted(current_owner=self.owner, pending_owner=new_owner)
Example:
Cross-links: accept_ownership
accept_ownership¶
Coil.accept_ownership()
Complete two-step ownership transfer initiated by transfer_ownership.
Returns / state: Sets owner = pending_owner, clears pending_owner.
Access: pending_owner only.
Events: OwnershipTransferred(previous_owner, new_owner).
Source (Coil-DEX/src/Coil.vy:239-246):
@external
def accept_ownership():
pending: address = self.pending_owner
assert msg.sender == pending, "Coil: not pending owner"
log OwnershipTransferred(previous_owner=self.owner, new_owner=pending)
self.owner = pending
self.pending_owner = empty(address)
Example:
Cross-links: transfer_ownership · set_solver
set_solver¶
Coil.set_solver(solver, authorized)
Authorize or de-authorize address to submit execute_batch settlements.
| Param | Type | Description |
|---|---|---|
solver |
address |
Solver address |
authorized |
bool |
True to allow batch submission |
Returns / state: Updates authorized_solvers[solver].
Access: owner only. Reverts if solver == empty(address).
Events: SolverUpdated(solver, authorized).
Source (Coil-DEX/src/Coil.vy:249-255):
@external
def set_solver(solver: address, authorized: bool):
assert msg.sender == self.owner, "Coil: not owner"
assert solver != empty(address), "Coil: zero solver"
self.authorized_solvers[solver] = authorized
log SolverUpdated(solver=solver, authorized=authorized)
Example:
Cross-links: execute_batch · Solver concept
set_token_allowed¶
Coil.set_token_allowed(token, allowed)
Allow or disallow ERC-20 for batch settlement. Caches decimal-aware dust floor on allowlist.
| Param | Type | Description |
|---|---|---|
token |
address |
ERC-20 to allow/disallow |
allowed |
bool |
True to permit in intents/batches |
Returns / state: Updates allowed_tokens[token]. When allowed, sets min_order_size[token] = max(1, 10**decimals / MIN_ORDER_DIVISOR) (0.0001 whole token). Clears to 0 when disallowed.
Access: owner only. Reverts if token == empty or decimals() > 18.
Policy: Standard non-rebasing, non-fee-on-transfer ERC-20s only — settlement assumes 1:1 transfer amounts.
Events: TokenAllowed(token, allowed).
Source (Coil-DEX/src/Coil.vy:258-281):
@external
def set_token_allowed(token: address, allowed: bool):
assert msg.sender == self.owner, "Coil: not owner"
self.allowed_tokens[token] = allowed
if allowed:
token_decimals: uint256 = convert(staticcall IERC20(token).decimals(), uint256)
self.min_order_size[token] = max(1, (10 ** token_decimals) // MIN_ORDER_DIVISOR)
else:
self.min_order_size[token] = 0
log TokenAllowed(token=token, allowed=allowed)
Example:
Cross-links: execute_batch · is_fillable
set_paused¶
Coil.set_paused(paused_)
Pause or unpause all batch settlement.
| Param | Type | Description |
|---|---|---|
paused_ |
bool |
True to halt execute_batch; False to resume |
Returns / state: Sets paused storage.
Access: owner only.
Events: Paused(paused).
Source (Coil-DEX/src/Coil.vy:284-289):
@external
def set_paused(paused_: bool):
assert msg.sender == self.owner, "Coil: not owner"
self.paused = paused_
log Paused(paused=paused_)
Example:
Cross-links: execute_batch · cancel_intent
set_fee_bps¶
Coil.set_fee_bps(new_fee_bps)
Set protocol fee charged on each intent fill in execute_batch.
| Param | Type | Description |
|---|---|---|
new_fee_bps |
uint256 |
Fee in basis points (0–MAX_FEE_BPS, max 100 = 1%) |
Returns / state: Updates fee_bps. Accrued to accrued_fees[buy_token] per fill.
Access: owner only.
Events: FeeUpdated(fee_bps).
Source (Coil-DEX/src/Coil.vy:292-298):
@external
def set_fee_bps(new_fee_bps: uint256):
assert msg.sender == self.owner, "Coil: not owner"
assert new_fee_bps <= MAX_FEE_BPS, "Coil: fee too high"
self.fee_bps = new_fee_bps
log FeeUpdated(fee_bps=new_fee_bps)
Example:
Cross-links: set_fee_recipient · withdraw_accrued_fees · CoilFeeRouter
set_fee_recipient¶
Coil.set_fee_recipient(new_recipient)
Set address receiving accrued protocol fees via withdraw_accrued_fees. FLYWHEEL 2.0: typically CoilFeeRouter.
| Param | Type | Description |
|---|---|---|
new_recipient |
address |
Fee sink (non-zero) |
Returns / state: Updates fee_recipient storage.
Access: owner only.
Events: FeeRecipientUpdated(recipient).
Source (Coil-DEX/src/Coil.vy:301-307):
@external
def set_fee_recipient(new_recipient: address):
assert msg.sender == self.owner, "Coil: not owner"
assert new_recipient != empty(address), "Coil: zero recipient"
self.fee_recipient = new_recipient
log FeeRecipientUpdated(recipient=new_recipient)
Example:
Cross-links: set_fee_bps · withdraw_accrued_fees · CoilFeeRouter harvest
Settlement¶
withdraw_accrued_fees¶
Coil.withdraw_accrued_fees(token)
Sweep all accrued protocol fees for token to fee_recipient. Permissionless.
| Param | Type | Description |
|---|---|---|
token |
address |
Fee token to sweep (typically buy-side token from fills) |
Returns / state: Zeros accrued_fees[token], transfers full accrued amount to fee_recipient.
Access: Any caller (@nonreentrant). Reverts if accrued_fees[token] == 0.
Events: None (transfer only). Fees accrued during execute_batch emit ProtocolFeesAccrued.
Source (Coil-DEX/src/Coil.vy:310-317):
@external
@nonreentrant
def withdraw_accrued_fees(token: address):
amount: uint256 = self.accrued_fees[token]
assert amount > 0, "Coil: no fees"
self.accrued_fees[token] = 0
self._safe_transfer(token, self.fee_recipient, amount)
Example:
Cross-links: set_fee_recipient · CoilFeeRouter harvest · execute_batch
withdraw_token¶
Coil.withdraw_token(token, to, amount)
Owner withdraws non-fee token balance held by contract. Cannot touch accrued_fees.
| Param | Type | Description |
|---|---|---|
token |
address |
ERC-20 to withdraw |
to |
address |
Recipient |
amount |
uint256 |
Amount to send |
Returns / state: Transfers amount from balanceOf(contract) - accrued_fees[token].
Access: owner only. Reverts if amount > available or to == empty.
Events: TokenWithdrawn(token, to, amount).
Source (Coil-DEX/src/Coil.vy:320-335):
@external
@nonreentrant
def withdraw_token(token: address, to: address, amount: uint256):
assert msg.sender == self.owner, "Coil: not owner"
accrued: uint256 = self.accrued_fees[token]
available: uint256 = staticcall IERC20(token).balanceOf(self) - accrued
assert amount <= available, "Coil: exceeds withdrawable"
self._safe_transfer(token, to, amount)
log TokenWithdrawn(token=token, to=to, amount=amount)
Example:
Cross-links: withdraw_accrued_fees · execute_batch
cancel_intent¶
Coil.cancel_intent(intent)
Cancel open intent on-chain. Sets filled_amount[intent_hash] = max_uint256 sentinel.
| Param | Type | Description |
|---|---|---|
intent |
Intent |
Full intent struct (must match signed payload) |
Returns / state: Marks intent cancelled; no longer fillable via execute_batch or is_fillable.
Access: intent.owner only (@nonreentrant). Reverts if already cancelled or fully filled.
Events: IntentCancelled(intent_hash, owner).
Source (Coil-DEX/src/Coil.vy:342-353):
@external
@nonreentrant
def cancel_intent(intent: Intent):
assert intent.owner == msg.sender, "Coil: not intent owner"
intent_hash: bytes32 = self._hash_intent(intent)
prior: uint256 = self.filled_amount[intent_hash]
assert prior != max_value(uint256), "Coil: already cancelled"
assert prior < intent.sell_amount, "Coil: already filled"
self.filled_amount[intent_hash] = max_value(uint256)
log IntentCancelled(intent_hash=intent_hash, owner=msg.sender)
Example:
Cross-links: intent_digest · is_fillable · Intents concept
execute_batch¶
Coil.execute_batch(intents, signatures, fill_amounts, price_numerator, price_denominator, sell_token, buy_token, permits)
Core settlement. Authorized solver clears batch at one uniform clearing price. All-or-nothing — any invalid intent reverts whole batch.
| Param | Type | Description |
|---|---|---|
intents |
Intent[] |
Signed orders (max 50) |
signatures |
Signature[] |
EIP-712 sig per intent (EOA or EIP-1271) |
fill_amounts |
uint256[] |
Sell-side fill per intent (partial fills OK) |
price_numerator / price_denominator |
uint256 |
Uniform clearing price: buy = sell × num / den |
sell_token / buy_token |
address |
Batch token pair (must be allowlisted) |
permits |
PermitData[] |
Optional ERC-2612 permits (failures swallowed) |
Returns / state: Updates filled_amount per intent; accrues fees to accrued_fees[buy_token]; increments batch_counter.
Flow:
1. Phase 1 — validate sigs, expiry, limits, cumulative allowance/balance per owner (transient storage).
2. Phase 2 — solver pulls buy_token; makers send sell_token to solver, receive buy_token minus fee.
Access: authorized_solvers only. Reverts if paused.
Events: IntentFilled per intent; ProtocolFeesAccrued; BatchSettled.
Source (Coil-DEX/src/Coil.vy:360-557):
@external
@nonreentrant
def execute_batch(intents, signatures, fill_amounts, price_numerator, price_denominator, sell_token, buy_token, permits):
# Phase 1: verify sigs, update filled_amount, check limits + allowance
gross_buy = fill_amt * price_numerator // price_denominator
fee_amount = gross_buy * fee_bps // FEE_DENOMINATOR
user_buy = gross_buy - fee_amount
# Phase 2: solver pulls buy_token, transfer sell to solver, buy to makers
self.accrued_fees[buy_token] += total_fee_accrued
Example:
Cross-links: set_solver · cancel_intent · withdraw_accrued_fees · Batch auction concept · Solver concept
Views¶
DOMAIN_SEPARATOR¶
Coil.DOMAIN_SEPARATOR() -> bytes32
Live EIP-712 domain separator for intent signing. Chain-fork aware.
Returns: bytes32 — domain separator. Uses cached INITIAL_DOMAIN_SEPARATOR when chain.id == INITIAL_CHAIN_ID; rebuilds on fork.
Domain: name "KhomDev Coil", version "6", chainId, verifyingContract = self.
Access: view, any caller.
Source (Coil-DEX/src/Coil.vy:564-568, _domain_separator:626-638):
Example:
Cross-links: intent_digest · hash_intent · Intents concept
hash_intent¶
Coil.hash_intent(intent) -> bytes32
EIP-712 struct hash of an Intent (typehash + encoded fields).
| Param | Type | Description |
|---|---|---|
intent |
Intent |
{owner, sell_token, buy_token, sell_amount, min_buy_amount, deadline, nonce} |
Returns: bytes32 — keccak256(typehash ‖ abi.encode(fields)). Used as inner hash for signing; combine with domain via intent_digest.
Access: view, any caller.
Source (Coil-DEX/src/Coil.vy:570-574, _hash_intent:610-622):
@external
@view
def hash_intent(intent: Intent) -> bytes32:
return self._hash_intent(intent)
# INTENT_TYPEHASH = keccak256("Intent(address owner,address sell_token,...)")
Example:
Cross-links: DOMAIN_SEPARATOR · intent_digest · is_fillable
intent_digest¶
Coil.intent_digest(intent) -> bytes32
Full EIP-712 digest to sign — \x19\x01 ‖ domain ‖ struct hash.
| Param | Type | Description |
|---|---|---|
intent |
Intent |
Intent struct to hash |
Returns: bytes32 — keccak256(0x1901 ‖ DOMAIN_SEPARATOR() ‖ hash_intent(intent)). This is what users/solvers sign.
Access: view, any caller.
Source (Coil-DEX/src/Coil.vy:576-580):
@external
@view
def intent_digest(intent: Intent) -> bytes32:
return keccak256(concat(b"\x19\x01", self._domain_separator(), self._hash_intent(intent)))
Example:
Cross-links: hash_intent · DOMAIN_SEPARATOR · execute_batch · Intents concept
is_fillable¶
Coil.is_fillable(intent) -> bool
Preflight check whether intent can be included in a batch right now.
| Param | Type | Description |
|---|---|---|
intent |
Intent |
Intent to check |
Returns: bool — True if not paused, owner non-zero, not fully filled/cancelled, above dust floor, not expired, both tokens allowlisted.
Access: view, any caller. Does not check signature, allowance, or balance.
Source (Coil-DEX/src/Coil.vy:582-601):
@external
@view
def is_fillable(intent: Intent) -> bool:
if self.paused: return False
h: bytes32 = self._hash_intent(intent)
if self.filled_amount[h] >= intent.sell_amount: return False
if intent.deadline < block.timestamp: return False
if not self.allowed_tokens[intent.sell_token]: return False
return True
Example:
if coil.is_fillable(intent):
# solver can try batch inclusion (still needs sig + allowance checks)
...
Cross-links: cancel_intent · execute_batch · intent_digest