Skip to content

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 fillsfilled_amount[intent_hash] tracks cumulative sell-side fill.
  • Signatures — EOA via ecrecover; smart wallets via EIP-1271.
  • Feesfee_bps (max 1%) accrued per fill; withdraw_accrued_fees to fee_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:

coil.transfer_ownership(new_admin)

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:

coil.accept_ownership()  # called by pending_owner

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:

coil.set_solver(rust_solver, True)

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:

coil.set_token_allowed(usdc, True)

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:

coil.set_paused(True)   # circuit breaker
coil.set_paused(False)  # resume

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 (0MAX_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:

coil.set_fee_bps(30)  # 0.30% protocol fee

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:

coil.set_fee_recipient(coil_fee_router)

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:

coil.withdraw_accrued_fees(usdc)  # sweeps to CoilFeeRouter

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:

coil.withdraw_token(stranded_token, treasury, 1_000 * 10**6)

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:

coil.cancel_intent(my_intent)

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:

coil.execute_batch(intents, sigs, fills, price_num, price_den, weth, usdc, permits)

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):

@external
@view
def DOMAIN_SEPARATOR() -> bytes32:
    return self._domain_separator()

Example:

domain = coil.DOMAIN_SEPARATOR()

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: bytes32keccak256(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:

struct_hash = coil.hash_intent(intent)

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: bytes32keccak256(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:

digest = coil.intent_digest(intent)  # sign this off-chain

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: boolTrue 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