feat(swap): HTLC atomic-swap precompile LP-90A0 — non-custodial cross-chain settlement

lock/claim/refund with SHA-256 hashlock (BTC OP_SHA256 parity), value-safe
transferFrom-delta funds-in, effects-before-interaction reentrancy safety,
conservation invariant (balanceOf >= Σreserve, paid-out <= locked-in), disjoint
claim(t<timeout)/refund(t>=timeout). No owner/pause/mint selector — non-custody
structural. ERC20/wrapped only (native needs GetCallValue seam). Replaces the
custodial lock-mint for cross-chain atomic swaps. Design: docs/htlc_atomic_swap.tex.
Registered on C + Zoo. 17 tests incl. -race.
This commit is contained in:
Lux
2026-06-26 02:21:16 +00:00
parent 783b05ad79
commit 88fb997ed7
9 changed files with 2243 additions and 2 deletions
+485
View File
@@ -0,0 +1,485 @@
% Copyright (C) 2026, Lux Industries Inc. All rights reserved.
% Lux DEX cross-chain HTLC atomic-swap settlement — construction, precompile
% interface, and threat model. Replaces the custodial lock-and-mint bridge and
% the dexvm custodial proxy. This document is normative: implement from it.
\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{amsmath,amssymb,amsthm}
\usepackage{enumitem}
\usepackage{booktabs}
\usepackage{listings}
\usepackage{hyperref}
\usepackage{xcolor}
\lstset{basicstyle=\ttfamily\small,breaklines=true,columns=fullflexible,
keywordstyle=\color{blue!60!black},commentstyle=\color{green!40!black}}
\newtheorem{theorem}{Theorem}
\newtheorem{lemma}{Lemma}
\newtheorem{definition}{Definition}
\newtheorem{invariant}{Invariant}
\title{\textbf{Lux DEX Cross-Chain Atomic Settlement}\\
\large Hash Time-Locked Contracts for Non-Custodial RFQ Settlement of Real BTC/ETH}
\author{Lux Cryptography}
\date{2026}
\begin{document}
\maketitle
\begin{abstract}
We replace the Lux DEX custodial lock-and-mint bridge and the \texttt{dexvm}
custodial atomic proxy with a genuine two-chain \emph{hash time-locked contract}
(HTLC) atomic swap. The settlement venue degenerates to a pure RFQ
\emph{coordinator}: it matches a user (taker) to a CEX-backed filler (maker),
relays a hashlock and on-chain lock proofs, and \emph{never holds keys, never
holds funds, and is never a recipient or refund party of any lock}. Settlement
either completes in full on both chains or both legs refund to their original
owners; no third party can mint, seize, or strand funds. We give the protocol,
the explicit timeout-ordering rule that guarantees atomicity, the on-Lux HTLC
precompile interface (selector ABI, state layout, events, per-function
verification), the Bitcoin~/~EVM counterparty-leg scripts, a threat model with
mitigations for every classical atomic-swap attack (free option, griefing,
preimage front-running, dust/fee griefing, reorg), and the migration that makes
every custodial mint path unreachable.
\end{abstract}
\section{Threat Model and Goals}
\paragraph{Adversary.} Any subset of: the venue/sequencer, the maker, a
network-level observer/front-runner, a chain reorg adversary bounded by $k$
blocks, and a malicious validator. The adversary is computationally bounded and
cannot break SHA-256 (preimage / collision resistance) or the signature schemes
of the two settlement chains (ECDSA/secp256k1 on Bitcoin and Lux~C-Chain).
\paragraph{Security goals.}
\begin{enumerate}[label=(G\arabic*),leftmargin=3.5em]
\item \textbf{Non-custody.} No party other than the asset's owner can move it.
The venue holds no key and appears in no lock as recipient or refund
address.
\item \textbf{Atomicity.} For an honest party that follows the protocol and is
online within its claim window, the swap either completes on both legs
or refunds on both legs. An honest party never ends with strictly less
than it started (modulo agreed swap terms and gas).
\item \textbf{No mint.} No wrapped asset is created; every unit paid out on
either leg was locked by its owner on that same leg. There is no
cross-chain supply.
\item \textbf{Censorship/strand resistance.} Neither counterparty nor the
venue can lock the other's funds indefinitely; every lock has an
unconditional refund after its timeout.
\end{enumerate}
\section{Roles and the RFQ Flow}
\begin{definition}[Parties]
\textbf{$M$ (maker / filler)}: a CEX-backed market maker holding inventory on
both chains, who answers RFQ quotes and hedges on a centralized venue.
\textbf{$U$ (user / taker)}: holds the asset to be sold on one chain and wants
the asset on the other. \textbf{$V$ (venue)}: RFQ coordinator; matches, relays
messages and lock proofs; holds no funds, no keys, no preimage.
\end{definition}
We swap $M$'s asset on chain $A$ for $U$'s asset on chain $B$ (e.g. $A=$
Lux~C-Chain USDC, $B=$ Bitcoin). The assignment is deliberate (\S\ref{sec:to}):
\textbf{the maker locks first; the user generates the secret and is the
secret-holder.} This puts \emph{all} structural optionality on the professional
maker (who prices it into the quote) and gives the retail user the protected
position.
\begin{enumerate}[leftmargin=2.2em]
\item \textbf{Quote.} $V$ publishes fill options (aggregated CEX prices /
oracle). $U$ requests a quote for size $q$. $M$ returns a firm quote
$(\text{rate}, \text{amount}_A, \text{amount}_B)$ valid for a short
window $w$ (e.g. $w=30$\,s), signed by $M$.
\item \textbf{Commit hashlock.} $U$ draws a uniform secret
$s \xleftarrow{\$} \{0,1\}^{256}$, computes $h=\mathrm{SHA256}(s)$, and
sends $h$ (with the accepted quote) to $M$ via $V$. $s$ never leaves
$U$ until the claim.
\item \textbf{Leg~A lock (maker first).} $M$ locks on chain $A$:
$\mathrm{HTLC}_A(h,\ \text{recipient}=U,\ \text{refund}=M,\
\text{asset}_A,\ \text{amount}_A,\ T_A)$. $M$ publishes the lock
proof (tx id) to $V$; $V$ relays to $U$.
\item \textbf{Verify + Leg~B lock (taker second).} $U$ waits for
$K_A$ confirmations of $\mathrm{HTLC}_A$, checks $h$, amount, recipient
$=U$, and $T_A$. If anything is wrong, $U$ does nothing and $M$ refunds
at $T_A$. Else $U$ locks on chain $B$:
$\mathrm{HTLC}_B(h,\ \text{recipient}=M,\ \text{refund}=U,\
\text{asset}_B,\ \text{amount}_B,\ T_B)$ with $T_B > T_A + \Delta$
(\S\ref{sec:to}). $U$ publishes the proof.
\item \textbf{Claim (reveals $s$).} $U$ claims $\mathrm{HTLC}_A$ on chain $A$
with $\mathrm{claim}(s)$, receiving $\text{amount}_A$. This publishes
$s$ on chain $A$. $U$ must claim before $T_A$.
\item \textbf{Counter-claim.} $M$ reads $s$ from chain $A$ (the
\texttt{Claimed} event / witness) and claims $\mathrm{HTLC}_B$ on chain
$B$ with $\mathrm{claim}(s)$ before $T_B$, receiving $\text{amount}_B$.
\item \textbf{Refund (only off the happy path).} If $U$ never reveals $s$,
$\mathrm{HTLC}_A$ refunds to $M$ at $T_A$ and $\mathrm{HTLC}_B$ refunds
to $U$ at $T_B$. Both sides whole.
\end{enumerate}
After step~3 the venue is irrelevant to safety: steps 4--7 are peer-to-peer
on-chain operations that $U$, $M$, or their watchtowers execute directly. $V$
crashing or lying can only deny service or relay a stale quote --- never move
funds.
\section{The Timeout-Ordering Rule (atomicity invariant)}
\label{sec:to}
\begin{definition}[Secret-holder]
The secret-holder is the party that generates $s$ and whose claim first
publishes $s$. Here it is the user $U$.
\end{definition}
\begin{theorem}[Timeout ordering]
\label{thm:to}
Let the secret-holder claim the lock with the \emph{earlier} timeout and let the
counterparty claim the lock with the \emph{later} timeout. Atomicity (G2) holds
for online honest parties iff
\[
T_{\text{late}} \;>\; T_{\text{early}} + \Delta,
\qquad
\Delta \;\ge\; R_{\text{late}} + C_{\text{late}},
\]
where $R_{\text{late}}$ is the reorg-safety depth (expressed in time) of the
chain holding the later-expiry leg and $C_{\text{late}}$ is the worst-case time
to confirm a claim there.
\end{theorem}
In our assignment the secret-holder $U$ claims $\mathrm{HTLC}_A$ (timeout $T_A$,
earlier) and the counterparty $M$ claims $\mathrm{HTLC}_B$ (timeout $T_B$,
later). Hence the normative rule:
\begin{center}\fbox{\parbox{0.92\linewidth}{\centering
\textbf{Maker's leg expires first; user's leg expires last:}\quad
$T_B > T_A + \Delta$.\\
The secret-holder's claim target ($\mathrm{HTLC}_A$) has the shorter timeout;
the counterparty's claim target ($\mathrm{HTLC}_B$) has the longer timeout.}}
\end{center}
\begin{proof}[Atomicity sketch]
Two outcomes only.
\emph{(i) $U$ reveals $s$ before $T_A$.} Then $s$ is public on chain $A$ at some
$t^\ast < T_A < T_B-\Delta$. $M$, online, claims $\mathrm{HTLC}_B$ in
$[t^\ast, T_B)$; the window has length $> \Delta \ge R_{\text{late}} +
C_{\text{late}}$, so even under a $k$-block reorg of chain $B$ the claim
confirms before $T_B$. Both legs claimed: complete.
\emph{(ii) $U$ does not reveal $s$ before $T_A$.} $\mathrm{HTLC}_A$ refunds to
$M$ at $T_A$. Since $s$ is unknown to $M$ (SHA-256 preimage resistance) and to
everyone else, $\mathrm{HTLC}_B$ cannot be claimed and refunds to $U$ at $T_B$.
Both legs refunded: rollback.
A reorg that removes $U$'s revealing claim on chain $A$ does \emph{not}
un-reveal $s$: once observed, $s$ is known, so $M$ can still claim
$\mathrm{HTLC}_B$. Recipient binding (\S\ref{sec:precompile}) ensures the
revealed $s$ enables payment only to the fixed recipients, never to a
front-runner.
\end{proof}
\paragraph{Concrete parameters.}
With $A=$ Lux (sub-second deterministic finality, $R_A\approx 0$) and $B=$
Bitcoin: set $K_A=$ Lux-finalized (1 block), $K_B=6$ confirmations,
$T_A = t_0 + 40\text{ min}$, $\Delta \ge 2\text{ h}$ (covering a 6-block BTC
reorg $\approx 60$\,min plus claim confirmation), $T_B = T_A + \Delta =
t_0 + 2\text{h}\,40\text{m}$. Bitcoin timeouts use \texttt{OP\_CHECKLOCKTIMEVERIFY}
on median-time-past; Lux timeouts use block timestamp. When both legs are EVM,
$\Delta$ shrinks to a few minutes.
\section{On-Lux HTLC Precompile (LP-90A0, \texttt{0x{\dots}90A0})}
\label{sec:precompile}
The HTLC is an \emph{orthogonal new primitive} with its own slot, not bolted
onto the AMM (\texttt{0x9010}) or CLOB (\texttt{0x9020}). It lives in a new
package \texttt{github.com/luxfi/precompile/swap} registered at
\texttt{0x0000{\dots}90A0} (\texttt{LXSwapAddress}, ``LP-90A0 LXSwap''), the next
free address after LP-9090 (LXSettlement). It reuses the proven-safe
\texttt{transferFrom}-delta custody idiom from \texttt{erc20\_vault.go} and the
standard \texttt{contract.StatefulPrecompiledContract} \texttt{Run} dispatch.
\subsection{Hash function: SHA-256 (cross-chain, not keccak)}
The hashlock MUST be $\mathrm{SHA256}$, matching Bitcoin's \texttt{OP\_SHA256}
and the EVM \texttt{0x02} precompile, so a single preimage $s$ unlocks both
legs. The preimage is fixed at exactly 32 bytes (Bitcoin witness compatibility).
\emph{The keccak commit--reveal in \texttt{hooks.go} is for the same-chain MEV
hook and is NOT reused here.}
\subsection{Selectors and Go interface}
\begin{lstlisting}[language=Go]
// Package swap is the Lux DEX cross-chain HTLC atomic-swap settlement
// precompile at LP-90A0. Non-custodial: funds move only to the recipient or
// refund address fixed at lock time; no owner, no pause, no upgrade.
package swap
const LXSwapAddress = "0x00000000000000000000000000000000000090A0"
// 4-byte selectors = BigEndian uint32 of keccak256(sig)[:4].
const (
SelectorLock uint32 = 0x........ // lock(bytes32,address,address,address,uint256,uint64)
SelectorClaim uint32 = 0x........ // claim(bytes32,bytes32)
SelectorRefund uint32 = 0x........ // refund(bytes32)
SelectorGetSwap uint32 = 0x........ // getSwap(bytes32) view
SelectorGetPreimage uint32 = 0x.... // getPreimage(bytes32) view (hashlock -> s)
)
type SwapContract struct{} // holds NO mutable Go state; all state in StateDB.
func (c *SwapContract) Run(
accessibleState contract.AccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) (ret []byte, remainingGas uint64, err error)
\end{lstlisting}
Internal handlers (mirroring the \texttt{0x9010} pattern):
\begin{lstlisting}[language=Go]
// lock(hashlock, recipient, refund, asset, amount, timeout) -> swapId
// asset == common.Address{} (zero) means native LUX.
// For native, requires the call-value seam (see Prerequisite below):
// accessibleState.GetCallValue() == amount. For ERC20, performs a real
// transferFrom and credits the OBSERVED balance delta (== amount), exactly
// as erc20_vault.go safeTransferTokenFrom.
func (c *SwapContract) runLock(st contract.AccessibleState, caller common.Address,
data []byte, gas uint64, readOnly bool) (swapId [32]byte, ret []byte, rem uint64, err error)
// claim(swapId, preimage) -> bool ; pays the STORED recipient (caller-agnostic).
func (c *SwapContract) runClaim(st contract.AccessibleState, caller common.Address,
data []byte, gas uint64, readOnly bool) (ret []byte, rem uint64, err error)
// refund(swapId) -> bool ; pays the STORED refund address after timeout.
func (c *SwapContract) runRefund(st contract.AccessibleState, caller common.Address,
data []byte, gas uint64, readOnly bool) (ret []byte, rem uint64, err error)
\end{lstlisting}
\subsection{State layout (StateDB on the precompile address)}
All persistent state lives in the EVM trie under \texttt{0x{\dots}90A0} using the
mapping-slot idiom $\textsf{slot}(id,f)=\mathrm{keccak256}(id \,\|\, f)$:
\begin{center}
\begin{tabular}{@{}lll@{}}
\toprule
Field & Slot key & Contents \\
\midrule
status & $\mathrm{keccak}(\text{swapId}\,\|\,0)$ & 0 None / 1 Locked / 2 Claimed / 3 Refunded \\
hashlock & $\mathrm{keccak}(\text{swapId}\,\|\,1)$ & $h=\mathrm{SHA256}(s)$ \\
recipient & $\mathrm{keccak}(\text{swapId}\,\|\,2)$ & payout address on claim \\
refund & $\mathrm{keccak}(\text{swapId}\,\|\,3)$ & payout address on refund \\
asset & $\mathrm{keccak}(\text{swapId}\,\|\,4)$ & ERC20 addr, or $0$ for native \\
amount & $\mathrm{keccak}(\text{swapId}\,\|\,5)$ & uint256 \\
timeout & $\mathrm{keccak}(\text{swapId}\,\|\,6)$ & uint64 unix seconds \\
preimage & $\mathrm{keccak}(\text{swapId}\,\|\,7)$ & $s$, zero until claimed \\
preimageIdx & $\mathrm{keccak}(\texttt{"pre"}\,\|\,h)$ & $s$ keyed by hashlock (cross-leg lookup) \\
reserve & $\mathrm{keccak}(\texttt{"res"}\,\|\,\text{asset})$ & $\sum$ live locked amount for asset \\
nonce & $\mathrm{keccak}(\texttt{"nonce"})$ & monotonic lock counter \\
\bottomrule
\end{tabular}
\end{center}
\[
\text{swapId}=\mathrm{keccak256}\big(h \,\|\, \text{recipient} \,\|\,
\text{refund} \,\|\, \text{asset} \,\|\, \text{amount} \,\|\, \text{timeout}
\,\|\, \text{caller} \,\|\, \text{nonce}\big).
\]
\begin{invariant}[Reserve / conservation]
\label{inv:res}
For every asset, $\mathrm{balanceOf}(\texttt{0x{\dots}90A0}) \ge
\sum_{\text{Locked swaps of asset}} \text{amount} = \textsf{reserve}[\text{asset}]$.
Each \texttt{lock} increments \textsf{reserve} by the \emph{observed} inbound
delta; each \texttt{claim}/\texttt{refund} decrements it by exactly the swap's
amount and pays out the same amount. No code path mints.
\end{invariant}
\subsection{Per-function verification (normative)}
\textbf{lock:}
\begin{enumerate}[noitemsep,leftmargin=2em]
\item \texttt{!readOnly}; else revert.
\item $h\neq 0$; recipient $\neq 0$; refund $\neq 0$.
\item $\text{amount} \ge \textsf{MinSwapAmount}$ (dust guard, per asset).
\item $T_{0}+\textsf{MinTimeout} \le \text{timeout} \le T_{0}+\textsf{MaxTimeout}$,
where $T_0=$ block timestamp. (Absolute sanity bounds; the relative
$T_B>T_A+\Delta$ ordering is chosen by the parties off-chain and
enforced by each party's own verification before locking.)
\item Move funds in: native $\Rightarrow$ \texttt{GetCallValue()}$=$amount;
ERC20 $\Rightarrow$ \texttt{transferFrom(caller,this,amount)} and require
observed $\Delta\mathrm{balanceOf}=$ amount (fee-on-transfer safe).
\item $\textsf{status}[\text{swapId}]=\text{None}$ (no collision); set fields;
$\textsf{reserve}[\text{asset}]\mathrel{+}=$ amount; \texttt{nonce}{+}{+};
emit \texttt{Locked}.
\end{enumerate}
\textbf{claim:}
\begin{enumerate}[noitemsep,leftmargin=2em]
\item \texttt{!readOnly}; load swap; require $\textsf{status}=\text{Locked}$.
\item require $T_0 < \text{timeout}$ (not expired).
\item require $\mathrm{len}(\text{preimage})=32$ and
$\mathrm{SHA256}(\text{preimage})=h$.
\item pay the \emph{stored} recipient (caller-agnostic: a watchtower may
submit); $\textsf{reserve}\mathrel{-}=$ amount; store preimage and
$\textsf{preimageIdx}[h]$; $\textsf{status}=\text{Claimed}$; emit
\texttt{Claimed}(swapId, $h$, preimage, recipient, amount).
\end{enumerate}
\textbf{refund:}
\begin{enumerate}[noitemsep,leftmargin=2em]
\item \texttt{!readOnly}; load; require $\textsf{status}=\text{Locked}$.
\item require $T_0 \ge \text{timeout}$.
\item pay the \emph{stored} refund address; $\textsf{reserve}\mathrel{-}=$
amount; $\textsf{status}=\text{Refunded}$; emit \texttt{Refunded}.
\end{enumerate}
The boundary is clean and disjoint: \texttt{claim} requires
$T_0<\text{timeout}$, \texttt{refund} requires $T_0\ge\text{timeout}$; a swap is
never both claimable and refundable, and (status$=$Locked once) never twice.
\paragraph{Events.}
\texttt{Locked(bytes32 indexed swapId, bytes32 indexed hashlock, address
recipient, address refund, address asset, uint256 amount, uint64 timeout)};
\texttt{Claimed(bytes32 indexed swapId, bytes32 indexed hashlock, bytes32
preimage, address recipient, uint256 amount)};
\texttt{Refunded(bytes32 indexed swapId, address refund, uint256 amount)}.
The \texttt{Claimed} event is the on-chain secret-relay: the counterparty's
watchtower subscribes by \texttt{hashlock} and reads \texttt{preimage}.
\paragraph{No admin surface.} \texttt{0x{\dots}90A0} has \emph{no} owner, pause,
freeze, upgrade, or set-balance selector. The only state transitions are
lock~$\to$~claim and lock~$\to$~refund, both permissionless and bounded by the
timeout. This is what makes (G1) and (G4) structural rather than policy.
\paragraph{Prerequisite (call-value seam).} Native-LUX locks require the
precompile to observe the EVM call value. The current
\texttt{StatefulPrecompiledContract.Run} signature carries no value (this is the
root of the \texttt{0x9010} native unbacked-mint finding). Add
\texttt{AccessibleState.GetCallValue() *big.Int} (or pass value into \texttt{Run})
in \texttt{contract/interfaces.go}, wired from the EVM call frame. Until that
lands, support ERC20/wrapped assets only (the \texttt{transferFrom}-delta path is
already value-safe).
\section{Counterparty-Leg Scripts}
\subsection{Bitcoin (P2WSH HTLC)}
\begin{lstlisting}
OP_IF
OP_SHA256 <h> OP_EQUALVERIFY
<recipient_pubkey> OP_CHECKSIG ; claim path: witness = <sig> <s> 1
OP_ELSE
<T_locktime> OP_CHECKLOCKTIMEVERIFY OP_DROP
<refund_pubkey> OP_CHECKSIG ; refund path: witness = <sig> 0
OP_ENDIF
\end{lstlisting}
$h=\mathrm{SHA256}(s)$, $|s|=32$. Timeout via absolute \texttt{OP\_CLTV}
(median-time-past). The refund spend sets \texttt{nLockTime}$\ge T$ and a
non-final sequence. Require $K_B=6$ confirmations before acting on a seen lock
and before treating a claim as final.
\subsection{EVM counterparty (Ethereum / another L2)}
Deploy the same HTLC logic as a Solidity contract (or this precompile on a
Lux-derived chain). Hashlock uses the \texttt{0x02} SHA-256 precompile, never
keccak, to remain preimage-compatible with the Bitcoin and Lux legs.
\section{Threat Model: Attacks and Mitigations}
\begin{center}\small
\begin{tabular}{@{}p{0.235\linewidth}p{0.715\linewidth}@{}}
\toprule
Attack & Mitigation \\
\midrule
\textbf{Free option / optionality.} Some party can wait and walk if price moves.
& Unavoidable in 2-of-2 HTLC; we \emph{allocate} it to the professional maker.
Maker locks first for a \emph{short} $T_A$ (e.g. 40\,min) and is the committed
party; the maker prices the option premium into the RFQ spread (it hedges on a
CEX). The user (taker) holds the option and the last-look --- the protected
side. Firm-quote window $w$ is seconds, bounding price drift. \\[2pt]
\textbf{Maker griefing / capital lockup.} A spam taker makes many makers lock,
then never proceeds, freezing maker capital until $T_A$.
& Bounded by the short $T_A$ (auto-refund). Venue-level rate-limiting and maker
reputation on takers. Optional hardening: taker posts the hashlock plus a small
slashable dust bond on the Lux leg \emph{before} the maker locks; the bond is
forfeit only if the taker fails to lock leg~B after the maker locks. The bond is
$\ll$ trade size, so it grants the maker no real option over the user. \\[2pt]
\textbf{Preimage front-running / observation.} An observer copies $s$ from the
mempool/witness when $U$ claims.
& Harmless: every HTLC binds payout to a \emph{fixed} recipient
(\texttt{CHECKSIG} to a specific pubkey on BTC; stored \texttt{recipient} on
Lux). Knowing $s$ lets one pay only the predetermined recipients, so a
front-runner at most completes the swap \emph{for} an honest party. Theft via
preimage requires an unbound ``anyone-with-$s$'' recipient, which we never use. \\[2pt]
\textbf{Fee / dust griefing.} A lock so small that the claim gas/fee exceeds the
output makes claiming irrational, stranding value.
& \textsf{MinSwapAmount} per asset on the Lux leg; Bitcoin outputs kept above
the dust limit and sized so $\text{amount}\gg$ claim fee. Refund is likewise
always economically rational. \\[2pt]
\textbf{Reorg on BTC/ETH leg.} A lock or claim is reorged out.
& Wait $K_A,K_B$ confirmations before treating a lock as real or a claim as
final; the safety buffer $\Delta \ge R_{\text{late}}+C_{\text{late}}$
(Thm.~\ref{thm:to}) covers a $k$-block reorg of the later-expiry leg. A reorg of
the secret-revealing claim does not un-reveal $s$. \\[2pt]
\textbf{Venue compromise / lying coordinator.} $V$ is malicious or offline.
& $V$ holds no key, no funds, and is no lock's recipient/refund. Worst case:
denial of service or a stale/bad quote (which the user can reject before
locking). Once both legs are locked, settlement is peer-to-peer; $V$ cannot move
funds. \\[2pt]
\textbf{Wrong-asset / amount / recipient in a lock.} Maker locks the wrong terms.
& The taker verifies $h$, asset, amount, recipient$=U$, and $T_A$ on-chain
\emph{before} locking leg~B (step~4). On mismatch the taker never locks; the
maker's lock simply refunds. \\[2pt]
\textbf{Hash-function mismatch across chains.} keccak vs SHA-256.
& Normative: SHA-256 hashlock on \emph{every} leg, $|s|=32$. The keccak
commit--reveal in \texttt{hooks.go} is a separate same-chain primitive and is
not used. \\
\bottomrule
\end{tabular}
\end{center}
\begin{theorem}[Non-custody]
Under the protocol, no party other than an asset's owner can transfer it, and
the venue can never transfer any asset.
\end{theorem}
\begin{proof}
Each unit sits in exactly one HTLC whose only exits are \texttt{claim} (to the
fixed recipient, gated by knowledge of $s$ with $\mathrm{SHA256}(s)=h$) and
\texttt{refund} (to the fixed refund address, gated by $T_0\ge T$). $V$ is
neither recipient nor refund of any HTLC and never learns $s$ before it is
public, so $V$ can call only \texttt{claim} with a public $s$ (paying the honest
recipient) or \texttt{refund} (paying the honest refund address) --- in both
cases value flows to the owner-designated address, not to $V$. The maker can
claim leg~B only after $U$ has already claimed leg~A (which revealed $s$),
i.e. only after $U$ received its side; by Invariant~\ref{inv:res} no path mints.
\end{proof}
\section{Migration: Removing Every Custodial Mint Path}
The audit found four reachable custodial/mint vectors. Each is removed or gated:
\begin{enumerate}[leftmargin=2em]
\item \textbf{Bridge lock-and-mint (\texttt{precompile/bridge}).} Delete /
disable \texttt{gateway.go} \texttt{CompleteBridge}, \texttt{InitiateBridge}
and the liquidity pools (the mint-on-no-op-signature path). Remove the
no-op verifiers \texttt{gateway.go:verifySignature} and
\texttt{signer.go:VerifyThresholdSignature}. The MPC signer set is
retained, if at all, only for non-custodial coordination (it can sign
\emph{advisory} RFQ attestations but holds no mint authority).
\item \textbf{\texttt{dexvm} custodial proxy (\texttt{luxfi/chains/dexvm}).}
Reject client-submitted \texttt{TxImport}/\texttt{TxExport} at admission
(\texttt{chainvm.go:SubmitTx}~/~\texttt{feegate.go:gateUserTxBytes}) and
in \texttt{vm.go:processTx}; the atomic shared-memory export leg
(\texttt{atomic.go:executeExport}) is no longer a settlement path.
Trades settle via the HTLC precompile between user and filler directly.
\item \textbf{EVM custody native-mint (\texttt{precompile/dex/pool\_manager.go}).}
Retire the custodial \texttt{deposit}/\texttt{withdraw} ZAP-credit rail
(the \texttt{0x9010} native-balance unbacked-mint at
\texttt{pool\_manager.go:1396}). The CLOB/RFQ venue keeps matching; final
asset settlement moves to \texttt{0x{\dots}90A0}.
\item \textbf{Stubbed light clients (\texttt{node/vms/chainadapter}).} No
longer on any settlement path: the HTLC legs are verified by each party's
own full node, not by the in-process stubs. Remove from the mint path;
if retained for watchtower convenience they must do real SPV before any
reliance.
\end{enumerate}
After migration the only cross-chain value movement is the HTLC pair; grepping
for a reachable mint (\texttt{AddBalance} not backed by an observed inbound
transfer, or a shared-memory \texttt{PutRequest} from client input) returns
nothing in the settlement path.
\section{Conclusion}
The construction makes non-custody (G1) and strand-resistance (G4) structural
properties of the on-chain HTLCs rather than policies enforced by a trusted
operator, and reduces atomicity (G2) and no-mint (G3) to SHA-256 preimage
resistance plus the timeout-ordering rule $T_B > T_A + \Delta$. The venue is
demoted to a coordinator that can delay or misquote but never steal, freeze, or
mint. This is the minimal trusted-computing base for real-asset DEX settlement.
\end{document}
+4 -2
View File
@@ -320,6 +320,7 @@ const (
LXLiquid = "0x0000000000000000000000000000000000009060" // LP-9060 LXLiquid (self-repaying loans)
Liquidator = "0x0000000000000000000000000000000000009070" // LP-9070 Liquidator (position liquidation)
LiquidFX = "0x0000000000000000000000000000000000009080" // LP-9080 LiquidFX (transmuter)
LXSwap = "0x00000000000000000000000000000000000090A0" // LP-90A0 LXSwap (cross-chain HTLC atomic swap)
)
// PrecompileAddress calculates address from (P, C, II) nibbles
@@ -413,7 +414,7 @@ var ChainPrecompiles = map[string][]string{
// AI (P=7)
GPUAttestCChain, TEEVerifyCChain, InferenceCChain, SessionCChain,
// DEX (LP-9xxx)
LXPool, LXRouter, LXHooks, LXFlash, LXOracle, LXBook, LXVault, LXFeed, LXLend, LXLiquid, Liquidator, LiquidFX,
LXPool, LXRouter, LXHooks, LXFlash, LXOracle, LXBook, LXVault, LXFeed, LXLend, LXLiquid, Liquidator, LiquidFX, LXSwap,
},
// Q-Chain (Quantum) - PQ and Threshold focused
@@ -457,7 +458,7 @@ var ChainPrecompiles = map[string][]string{
// Zoo - DEX focused (same precompile addresses)
"Zoo": {
// DEX (LP-9xxx) - same addresses as C-Chain
LXPool, LXRouter, LXHooks, LXFlash, LXOracle, LXBook, LXVault, LXFeed, LXLend, LXLiquid, Liquidator, LiquidFX,
LXPool, LXRouter, LXHooks, LXFlash, LXOracle, LXBook, LXVault, LXFeed, LXLend, LXLiquid, Liquidator, LiquidFX, LXSwap,
// Bridges for cross-chain trading
WarpSendCChain, WarpReceiveCChain,
},
@@ -562,6 +563,7 @@ var AllPrecompiles = []PrecompileInfo{
{LXLiquid, "LX_LIQUID", "Self-repaying loans (Alchemix-style)", 30000, []string{"C", "Zoo"}, "LP-9060"},
{Liquidator, "LIQUIDATOR", "Position liquidation engine", 50000, []string{"C", "Zoo"}, "LP-9070"},
{LiquidFX, "LIQUID_FX", "Transmuter (liquid token conversion)", 25000, []string{"C", "Zoo"}, "LP-9080"},
{LXSwap, "LX_SWAP", "Cross-chain HTLC atomic swap (non-custodial settlement)", 60000, []string{"C", "Zoo"}, "LP-90A0"},
}
// GetPrecompileAddress returns the address for a precompile by name
+65
View File
@@ -0,0 +1,65 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
ethtypes "github.com/luxfi/geth/core/types"
)
// Event topic0 hashes, derived from the canonical signature strings (the single
// source of truth, mirroring dex/events.go).
var (
lockedEventSig = common.BytesToHash(crypto.Keccak256([]byte("Locked(bytes32,bytes32,address,address,address,uint256,uint64)")))
claimedEventSig = common.BytesToHash(crypto.Keccak256([]byte("Claimed(bytes32,bytes32,bytes32,address,uint256)")))
refundedEventSig = common.BytesToHash(crypto.Keccak256([]byte("Refunded(bytes32,address,uint256)")))
)
type logSink interface{ AddLog(*ethtypes.Log) }
// emitLocked: Locked(bytes32 indexed swapId, bytes32 indexed hashlock, address
// recipient, address refund, address asset, uint256 amount, uint64 timeout).
func emitLocked(db logSink, swapId, hashlock common.Hash, recipient, refund, asset common.Address, amount *big.Int, timeout uint64) {
data := make([]byte, 0, 5*32)
data = append(data, addressWord(recipient)...)
data = append(data, addressWord(refund)...)
data = append(data, addressWord(asset)...)
data = append(data, common.BigToHash(amount).Bytes()...)
data = append(data, uint64Word(timeout)...)
db.AddLog(&ethtypes.Log{
Address: swapAddr,
Topics: []common.Hash{lockedEventSig, swapId, hashlock},
Data: data,
})
}
// emitClaimed: Claimed(bytes32 indexed swapId, bytes32 indexed hashlock, bytes32
// preimage, address recipient, uint256 amount). This is the on-chain secret relay:
// the counterparty's watchtower subscribes by hashlock and reads preimage.
func emitClaimed(db logSink, swapId, hashlock common.Hash, preimage [preimageLen]byte, recipient common.Address, amount *big.Int) {
data := make([]byte, 0, 3*32)
data = append(data, preimage[:]...)
data = append(data, addressWord(recipient)...)
data = append(data, common.BigToHash(amount).Bytes()...)
db.AddLog(&ethtypes.Log{
Address: swapAddr,
Topics: []common.Hash{claimedEventSig, swapId, hashlock},
Data: data,
})
}
// emitRefunded: Refunded(bytes32 indexed swapId, address refund, uint256 amount).
func emitRefunded(db logSink, swapId common.Hash, refund common.Address, amount *big.Int) {
data := make([]byte, 0, 2*32)
data = append(data, addressWord(refund)...)
data = append(data, common.BigToHash(amount).Bytes()...)
db.AddLog(&ethtypes.Log{
Address: swapAddr,
Topics: []common.Hash{refundedEventSig, swapId},
Data: data,
})
}
+266
View File
@@ -0,0 +1,266 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"context"
"encoding/binary"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// mockState implements contract.StateDB AND the erc20Vault capability. Token
// balances live in a simple ledger so the conservation invariant can be checked
// against the precompile's own reserve accounting.
// ---------------------------------------------------------------------------
type stateKey struct {
addr common.Address
key common.Hash
}
type mockState struct {
storage map[stateKey]common.Hash
logs []*ethtypes.Log
// token -> owner -> balance
tokens map[common.Address]map[common.Address]*big.Int
// negative-test knobs:
failTransfer map[common.Address]bool // tokens whose transfer always fails
feeBps map[common.Address]int // fee-on-transfer: bps withheld on transferFrom
reenter func() // invoked once inside the next transferFrom
}
func newMockState() *mockState {
return &mockState{
storage: make(map[stateKey]common.Hash),
tokens: make(map[common.Address]map[common.Address]*big.Int),
failTransfer: make(map[common.Address]bool),
feeBps: make(map[common.Address]int),
}
}
func (m *mockState) bal(token, owner common.Address) *big.Int {
if m.tokens[token] == nil {
return big.NewInt(0)
}
if v := m.tokens[token][owner]; v != nil {
return v
}
return big.NewInt(0)
}
func (m *mockState) setBal(token, owner common.Address, v *big.Int) {
if m.tokens[token] == nil {
m.tokens[token] = make(map[common.Address]*big.Int)
}
m.tokens[token][owner] = v
}
func (m *mockState) fund(token, owner common.Address, amt *big.Int) {
m.setBal(token, owner, new(big.Int).Add(m.bal(token, owner), amt))
}
// --- erc20Vault capability ---
func (m *mockState) TokenBalanceOf(token, owner common.Address) *big.Int {
return new(big.Int).Set(m.bal(token, owner))
}
func (m *mockState) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
if m.failTransfer[token] {
return errTokenReverted
}
if m.bal(token, from).Cmp(amount) < 0 {
return errTokenReverted
}
if r := m.reenter; r != nil {
m.reenter = nil // fire exactly once
r()
}
m.setBal(token, from, new(big.Int).Sub(m.bal(token, from), amount))
credit := amount
if bps := m.feeBps[token]; bps > 0 { // fee-on-transfer: deliver less than `amount`
fee := new(big.Int).Div(new(big.Int).Mul(amount, big.NewInt(int64(bps))), big.NewInt(10000))
credit = new(big.Int).Sub(amount, fee)
}
m.setBal(token, to, new(big.Int).Add(m.bal(token, to), credit))
return nil
}
func (m *mockState) TransferTokenTo(token, to common.Address, amount *big.Int) error {
if m.failTransfer[token] {
return errTokenReverted
}
if m.bal(token, swapAddr).Cmp(amount) < 0 {
return errTokenReverted
}
m.setBal(token, swapAddr, new(big.Int).Sub(m.bal(token, swapAddr), amount))
m.setBal(token, to, new(big.Int).Add(m.bal(token, to), amount))
return nil
}
// --- contract.StateDB ---
func (m *mockState) GetState(addr common.Address, key common.Hash) common.Hash {
return m.storage[stateKey{addr, key}]
}
func (m *mockState) SetState(addr common.Address, key, value common.Hash) common.Hash {
k := stateKey{addr, key}
prev := m.storage[k]
m.storage[k] = value
return prev
}
func (m *mockState) AddLog(l *ethtypes.Log) { m.logs = append(m.logs, l) }
func (m *mockState) Logs() []*ethtypes.Log { return m.logs }
func (m *mockState) TxHash() common.Hash { return common.Hash{} }
func (m *mockState) Snapshot() int { return 0 }
func (m *mockState) RevertToSnapshot(int) {}
func (m *mockState) CreateAccount(common.Address) {}
func (m *mockState) Exist(common.Address) bool { return true }
func (m *mockState) GetNonce(common.Address) uint64 { return 0 }
func (m *mockState) SetNonce(common.Address, uint64, tracing.NonceChangeReason) {}
func (m *mockState) GetBalance(common.Address) *uint256.Int { return uint256.NewInt(0) }
func (m *mockState) AddBalance(common.Address, *uint256.Int, tracing.BalanceChangeReason) uint256.Int {
return uint256.Int{}
}
func (m *mockState) SubBalance(common.Address, *uint256.Int, tracing.BalanceChangeReason) uint256.Int {
return uint256.Int{}
}
func (m *mockState) GetBalanceMultiCoin(common.Address, common.Hash) *big.Int { return big.NewInt(0) }
func (m *mockState) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (m *mockState) SubBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (m *mockState) GetPredicateStorageSlots(common.Address, int) ([]byte, bool) {
return nil, false
}
// ---------------------------------------------------------------------------
// mock AccessibleState + BlockContext with a settable clock.
// ---------------------------------------------------------------------------
type mockBlock struct{ ts uint64 }
func (b *mockBlock) Number() *big.Int { return big.NewInt(1) }
func (b *mockBlock) Timestamp() uint64 { return b.ts }
func (b *mockBlock) GetPredicateResults(common.Hash, common.Address) []byte { return nil }
type mockAccessible struct {
db *mockState
blk *mockBlock
}
func (a *mockAccessible) GetStateDB() contract.StateDB { return a.db }
func (a *mockAccessible) GetBlockContext() contract.BlockContext { return a.blk }
func (a *mockAccessible) GetConsensusContext() context.Context { return context.Background() }
func (a *mockAccessible) GetChainConfig() precompileconfig.ChainConfig { return nil }
func (a *mockAccessible) GetPrecompileEnv() contract.PrecompileEnvironment { return nil }
// ---------------------------------------------------------------------------
// env: a precompile + state + clock bundle with typed call helpers.
// ---------------------------------------------------------------------------
type env struct {
c *SwapContract
st *mockAccessible
gas uint64
}
// eqBig asserts two *big.Int values are numerically equal (Cmp == 0), avoiding
// the nil-vs-empty `abs` pitfall of reflect-based require.Equal on big.Int.
func eqBig(t require.TestingT, want, got *big.Int) {
require.Truef(t, want.Cmp(got) == 0, "want %s, got %s", want, got)
}
func newEnv(now uint64) *env {
return &env{
c: &SwapContract{},
st: &mockAccessible{db: newMockState(), blk: &mockBlock{ts: now}},
gas: 10_000_000,
}
}
func (e *env) now() uint64 { return e.st.blk.ts }
func (e *env) setNow(ts uint64) { e.st.blk.ts = ts }
func (e *env) db() *mockState { return e.st.db }
func (e *env) reserve(a common.Address) *big.Int { return loadReserve(e.db(), a) }
func selBytes(sel uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, sel)
return b
}
func leftPad(b []byte) []byte {
w := make([]byte, 32)
copy(w[32-len(b):], b)
return w
}
func addrArg(a common.Address) []byte { return leftPad(a.Bytes()) }
func amountArg(v *big.Int) []byte { return leftPad(v.Bytes()) }
func u64Arg(v uint64) []byte {
b := make([]byte, 8)
binary.BigEndian.PutUint64(b, v)
return leftPad(b)
}
// lock encodes and runs a lock call, returning the swapId and result.
func (e *env) lock(caller common.Address, hashlock common.Hash, recipient, refund, asset common.Address, amount *big.Int, timeout uint64, readOnly bool) (common.Hash, []byte, error) {
in := selBytes(selLock)
in = append(in, hashlock[:]...)
in = append(in, addrArg(recipient)...)
in = append(in, addrArg(refund)...)
in = append(in, addrArg(asset)...)
in = append(in, amountArg(amount)...)
in = append(in, u64Arg(timeout)...)
ret, _, err := e.c.Run(e.st, caller, swapAddr, in, e.gas, readOnly)
var id common.Hash
if err == nil {
copy(id[:], ret)
}
return id, ret, err
}
func (e *env) claim(caller common.Address, swapId common.Hash, preimage [32]byte, readOnly bool) ([]byte, error) {
in := selBytes(selClaim)
in = append(in, swapId[:]...)
in = append(in, preimage[:]...)
ret, _, err := e.c.Run(e.st, caller, swapAddr, in, e.gas, readOnly)
return ret, err
}
func (e *env) refund(caller common.Address, swapId common.Hash, readOnly bool) ([]byte, error) {
in := selBytes(selRefund)
in = append(in, swapId[:]...)
ret, _, err := e.c.Run(e.st, caller, swapAddr, in, e.gas, readOnly)
return ret, err
}
func (e *env) getSwap(swapId common.Hash, readOnly bool) ([]byte, error) {
in := append(selBytes(selGetSwap), swapId[:]...)
ret, _, err := e.c.Run(e.st, common.Address{}, swapAddr, in, e.gas, readOnly)
return ret, err
}
func (e *env) getPreimage(hashlock common.Hash, readOnly bool) ([32]byte, error) {
in := append(selBytes(selGetPreimage), hashlock[:]...)
ret, _, err := e.c.Run(e.st, common.Address{}, swapAddr, in, e.gas, readOnly)
var p [32]byte
if err == nil {
copy(p[:], ret)
}
return p, err
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"fmt"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
)
var (
_ contract.Configurator = (*configurator)(nil)
_ precompileconfig.Config = (*Config)(nil)
)
// ConfigKey is the JSON config key for the LXSwap (LP-90A0) precompile.
const ConfigKey = "swapConfig"
// SwapPrecompile is the singleton stateful contract. It holds no mutable Go state.
var SwapPrecompile = &SwapContract{}
// Module is the LP-90A0 LXSwap precompile module.
var Module = modules.Module{
ConfigKey: ConfigKey,
Address: swapAddr,
Contract: SwapPrecompile,
Configurator: &configurator{},
}
func init() {
if err := modules.RegisterModule(Module); err != nil {
panic(err)
}
}
// Config is the activation config. The HTLC has NO administrative parameters by
// design (no owner, no pause, no fee controller) — only the standard upgrade
// timestamp / disable flags.
type Config struct {
precompileconfig.Upgrade
}
func (c *Config) Key() string { return ConfigKey }
func (c *Config) Timestamp() *uint64 { return c.Upgrade.Timestamp() }
func (c *Config) IsDisabled() bool { return c.Upgrade.Disable }
func (c *Config) Equal(cfg precompileconfig.Config) bool {
other, ok := cfg.(*Config)
if !ok {
return false
}
return c.Upgrade.Equal(&other.Upgrade)
}
func (c *Config) Verify(chainConfig precompileconfig.ChainConfig) error { return nil }
// configurator implements contract.Configurator. There is nothing to configure:
// the precompile's behaviour is fully determined by its code and the on-chain
// state, never by an operator-supplied parameter.
type configurator struct{}
func (*configurator) MakeConfig() precompileconfig.Config { return new(Config) }
func (*configurator) Configure(
chainConfig precompileconfig.ChainConfig,
cfg precompileconfig.Config,
state contract.StateDB,
blockContext contract.ConfigurationBlockContext,
) error {
if _, ok := cfg.(*Config); !ok {
return fmt.Errorf("expected config type %T, got %T", &Config{}, cfg)
}
return nil
}
+267
View File
@@ -0,0 +1,267 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"crypto/sha256"
"encoding/binary"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// State layout under swapAddr, using the mapping-slot idiom slot(id,f) =
// keccak256(id ‖ f). Per-swap fields are keyed by (swapId, fieldByte); the
// singletons are keyed by fixed string prefixes.
const (
fStatus byte = 0 // 0 None / 1 Locked / 2 Claimed / 3 Refunded
fHashlock byte = 1
fRecipient byte = 2
fRefund byte = 3
fAsset byte = 4
fAmount byte = 5
fTimeout byte = 6
fPreimage byte = 7
)
var (
prefixPreimage = []byte("pre") // preimageIdx[hashlock] = s
prefixReserve = []byte("res") // reserve[asset] = Σ live locked amount
prefixNonce = []byte("nonce") // monotonic lock counter
prefixGuard = []byte("guard") // global non-reentrant entry guard
)
// swapFieldSlot = keccak256(swapId ‖ field).
func swapFieldSlot(swapId common.Hash, field byte) common.Hash {
return common.BytesToHash(crypto.Keccak256(swapId[:], []byte{field}))
}
// preimageIdxSlot = keccak256("pre" ‖ hashlock).
func preimageIdxSlot(hashlock common.Hash) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixPreimage, hashlock[:]))
}
// reserveSlot = keccak256("res" ‖ asset).
func reserveSlot(asset common.Address) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixReserve, asset.Bytes()))
}
// nonceSlot = keccak256("nonce"); guardSlot = keccak256("guard").
func nonceSlot() common.Hash { return common.BytesToHash(crypto.Keccak256(prefixNonce)) }
func guardSlot() common.Hash { return common.BytesToHash(crypto.Keccak256(prefixGuard)) }
// computeSwapID = keccak256(h ‖ recipient ‖ refund ‖ asset ‖ amount ‖ timeout ‖
// caller ‖ nonce) with amount and nonce as 32-byte big-endian words and timeout
// as an 8-byte big-endian word.
func computeSwapID(
hashlock common.Hash,
recipient, refund, asset common.Address,
amount *big.Int,
timeout uint64,
caller common.Address,
nonce uint64,
) common.Hash {
var amountW [32]byte
amount.FillBytes(amountW[:])
var timeoutW [8]byte
binary.BigEndian.PutUint64(timeoutW[:], timeout)
var nonceW [32]byte
binary.BigEndian.PutUint64(nonceW[24:], nonce)
return common.BytesToHash(crypto.Keccak256(
hashlock[:],
recipient.Bytes(),
refund.Bytes(),
asset.Bytes(),
amountW[:],
timeoutW[:],
caller.Bytes(),
nonceW[:],
))
}
// preimageMatches reports whether SHA256(preimage) == hashlock. The preimage is
// public once revealed, so a plain comparison is correct here — there is no secret
// to leak via timing.
func preimageMatches(preimage [preimageLen]byte, hashlock common.Hash) bool {
sum := sha256.Sum256(preimage[:])
return common.BytesToHash(sum[:]) == hashlock
}
// ---------------------------------------------------------------------------
// stateRW is the minimal StateDB surface this precompile touches: word-addressed
// storage plus the event log. contract.StateDB satisfies it.
// ---------------------------------------------------------------------------
type stateRW interface {
GetState(common.Address, common.Hash) common.Hash
SetState(common.Address, common.Hash, common.Hash) common.Hash
}
// ---------------------------------------------------------------------------
// Per-swap field accessors
// ---------------------------------------------------------------------------
func loadStatus(db stateRW, swapId common.Hash) uint8 {
return db.GetState(swapAddr, swapFieldSlot(swapId, fStatus))[31]
}
func storeStatus(db stateRW, swapId common.Hash, status uint8) {
var w common.Hash
w[31] = status
db.SetState(swapAddr, swapFieldSlot(swapId, fStatus), w)
}
func loadHash(db stateRW, swapId common.Hash, field byte) common.Hash {
return db.GetState(swapAddr, swapFieldSlot(swapId, field))
}
func storeHash(db stateRW, swapId common.Hash, field byte, v common.Hash) {
db.SetState(swapAddr, swapFieldSlot(swapId, field), v)
}
func loadAddress(db stateRW, swapId common.Hash, field byte) common.Address {
return common.BytesToAddress(db.GetState(swapAddr, swapFieldSlot(swapId, field)).Bytes())
}
func storeAddress(db stateRW, swapId common.Hash, field byte, a common.Address) {
db.SetState(swapAddr, swapFieldSlot(swapId, field), common.BytesToHash(a.Bytes()))
}
func loadAmount(db stateRW, swapId common.Hash, field byte) *big.Int {
return new(big.Int).SetBytes(db.GetState(swapAddr, swapFieldSlot(swapId, field)).Bytes())
}
func storeAmount(db stateRW, swapId common.Hash, field byte, amount *big.Int) {
db.SetState(swapAddr, swapFieldSlot(swapId, field), common.BigToHash(amount))
}
func loadTimeout(db stateRW, swapId common.Hash) uint64 {
w := db.GetState(swapAddr, swapFieldSlot(swapId, fTimeout))
return binary.BigEndian.Uint64(w[24:])
}
func storeTimeout(db stateRW, swapId common.Hash, timeout uint64) {
var w common.Hash
binary.BigEndian.PutUint64(w[24:], timeout)
db.SetState(swapAddr, swapFieldSlot(swapId, fTimeout), w)
}
// ---------------------------------------------------------------------------
// preimageIdx[hashlock]
// ---------------------------------------------------------------------------
func loadPreimageIdx(db stateRW, hashlock common.Hash) [preimageLen]byte {
var p [preimageLen]byte
copy(p[:], db.GetState(swapAddr, preimageIdxSlot(hashlock)).Bytes())
return p
}
func storePreimageIdx(db stateRW, hashlock common.Hash, preimage [preimageLen]byte) {
db.SetState(swapAddr, preimageIdxSlot(hashlock), common.BytesToHash(preimage[:]))
}
// ---------------------------------------------------------------------------
// reserve[asset] — the per-asset conservation accumulator.
// ---------------------------------------------------------------------------
func loadReserve(db stateRW, asset common.Address) *big.Int {
return new(big.Int).SetBytes(db.GetState(swapAddr, reserveSlot(asset)).Bytes())
}
func storeReserve(db stateRW, asset common.Address, v *big.Int) {
db.SetState(swapAddr, reserveSlot(asset), common.BigToHash(v))
}
func addReserve(db stateRW, asset common.Address, delta *big.Int) {
storeReserve(db, asset, new(big.Int).Add(loadReserve(db, asset), delta))
}
// subReserve debits the per-asset reserve, refusing (false) on underflow. Under
// the conservation invariant the reserve always covers a Locked swap's amount, so
// false signals a state-corruption bug, never a normal path.
func subReserve(db stateRW, asset common.Address, amount *big.Int) bool {
cur := loadReserve(db, asset)
if cur.Cmp(amount) < 0 {
return false
}
storeReserve(db, asset, new(big.Int).Sub(cur, amount))
return true
}
// ---------------------------------------------------------------------------
// nonce + reentrancy guard
// ---------------------------------------------------------------------------
func loadNonce(db stateRW) uint64 {
w := db.GetState(swapAddr, nonceSlot())
return binary.BigEndian.Uint64(w[24:])
}
func storeNonce(db stateRW, nonce uint64) {
var w common.Hash
binary.BigEndian.PutUint64(w[24:], nonce)
db.SetState(swapAddr, nonceSlot(), w)
}
// enterGuard sets the global guard, returning false if it was already set (a
// reentrant entry). exitGuard clears it. Together they make the transferFrom-delta
// measurement and the status flip atomic against a token's reentrant callback.
func enterGuard(db stateRW) bool {
slot := guardSlot()
if db.GetState(swapAddr, slot)[31] != 0 {
return false
}
var w common.Hash
w[31] = 1
db.SetState(swapAddr, slot, w)
return true
}
func exitGuard(db stateRW) {
db.SetState(swapAddr, guardSlot(), common.Hash{})
}
// ---------------------------------------------------------------------------
// ABI word helpers
// ---------------------------------------------------------------------------
// wordAddress reads an address from a right-aligned 32-byte ABI word.
func wordAddress(word []byte) common.Address {
return common.BytesToAddress(word[12:32])
}
// wordUint64 reads a uint64 from a right-aligned 32-byte ABI word, rejecting any
// value that does not fit in 64 bits (high 24 bytes must be zero).
func wordUint64(word []byte) (uint64, bool) {
for _, b := range word[:24] {
if b != 0 {
return 0, false
}
}
return binary.BigEndian.Uint64(word[24:32]), true
}
func addressWord(a common.Address) []byte { return common.BytesToHash(a.Bytes()).Bytes() }
func uint64Word(v uint64) []byte {
var w common.Hash
binary.BigEndian.PutUint64(w[24:], v)
return w.Bytes()
}
func uint8Word(v uint8) []byte {
var w common.Hash
w[31] = v
return w.Bytes()
}
func boolWord(b bool) []byte {
var w common.Hash
if b {
w[31] = 1
}
return w.Bytes()
}
+469
View File
@@ -0,0 +1,469 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package swap is the Lux DEX cross-chain HTLC atomic-swap settlement precompile
// at LP-90A0 (0x...90A0). It implements a genuine hash time-locked contract: an
// asset locked under a SHA-256 hashlock h moves ONLY to the recipient fixed at
// lock time (on revealing a preimage s with SHA256(s)=h) or back to the refund
// address fixed at lock time (after the timeout). There is no owner, no pause, no
// freeze, no upgrade, and no set-balance selector — non-custody (G1) and
// strand-resistance (G4) are STRUCTURAL, not policy. No code path mints: every
// unit paid out was locked by its owner via a real ERC-20 transferFrom whose
// OBSERVED inbound delta backs the per-asset reserve ledger (Invariant: reserve).
//
// Hashlock is SHA-256 (matching Bitcoin OP_SHA256 and the EVM 0x02 precompile),
// NOT keccak — a single 32-byte preimage unlocks both legs of a cross-chain swap.
// The keccak commitreveal in dex/hooks.go is a separate same-chain primitive and
// is not reused here.
//
// This precompile reuses the proven-safe transferFrom-delta custody idiom from
// dex/erc20_vault.go (observe balanceOf delta, never trust the requested amount)
// and the standard contract.StatefulPrecompiledContract Run selector dispatch.
package swap
import (
"encoding/binary"
"errors"
"fmt"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
var _ contract.StatefulPrecompiledContract = (*SwapContract)(nil)
// LXSwapAddress is LP-90A0 "LXSwap": the on-Lux HTLC atomic-swap settlement
// precompile, the next free DEX-page address after LP-9090 (LXSettlement).
const LXSwapAddress = "0x00000000000000000000000000000000000090A0"
// swapAddr is the precompile's own address; all persistent state and all locked
// token balances live under it.
var swapAddr = common.HexToAddress(LXSwapAddress)
// methodSelector derives the 4-byte selector (BigEndian uint32 of keccak256(sig)[:4])
// from a canonical ABI signature. The signature string is the SINGLE source of
// truth — no transcribed magic numbers can drift from it.
func methodSelector(sig string) uint32 {
return binary.BigEndian.Uint32(crypto.Keccak256([]byte(sig))[:contract.SelectorLen])
}
// 4-byte selectors. The trailing hex is the authoritative value (asserted in the
// test suite) but is derived, never hand-entered.
var (
selLock = methodSelector("lock(bytes32,address,address,address,uint256,uint64)") // 0x4da2c728
selClaim = methodSelector("claim(bytes32,bytes32)") // 0x84cc9dfb
selRefund = methodSelector("refund(bytes32)") // 0x7249fbb6
selGetSwap = methodSelector("getSwap(bytes32)") // 0x3da0e66e
selGetPreimage = methodSelector("getPreimage(bytes32)") // 0x0f622b04
)
// Status of a swap slot. None means "no swap at this id".
const (
StatusNone uint8 = 0
StatusLocked uint8 = 1
StatusClaimed uint8 = 2
StatusRefunded uint8 = 3
)
// Gas costs per operation. lock performs a token transferFrom sub-call plus
// several cold SSTOREs; claim/refund a transfer sub-call plus SSTOREs; the views
// are read-only.
const (
GasLock uint64 = 60000
GasClaim uint64 = 50000
GasRefund uint64 = 40000
GasGetSwap uint64 = 5000
GasGetPreimage uint64 = 3000
)
// preimageLen is the fixed preimage size: exactly 32 bytes, for Bitcoin witness
// compatibility (a bytes32 on the EVM legs, a 32-byte push on the BTC leg).
const preimageLen = 32
// Absolute sanity bounds on the timeout, measured from the block timestamp T0.
// These are coarse guards; the relative ordering T_B > T_A + Δ that guarantees
// atomicity is chosen off-chain by the parties and enforced by each party's own
// pre-lock verification (see the design doc, §timeout ordering). All three are
// vars so a chain may tune them via config without forking the ABI.
var (
// MinSwapAmount is the per-lock dust floor: a lock below it is rejected so the
// claim/refund gas can never exceed the output (fee-griefing guard). The global
// default is the minimal sane floor (strictly positive); per-asset tuning is a
// future configuration concern.
MinSwapAmount = big.NewInt(1)
// MinTimeout / MaxTimeout bound (timeout - T0): at least 10 minutes (so a real
// counterparty has time to act), at most 30 days (so capital is never locked
// unboundedly).
MinTimeout uint64 = 600
MaxTimeout uint64 = 30 * 24 * 60 * 60
)
var (
ErrReadOnly = errors.New("swap: cannot modify state in read-only (static) call")
ErrReentrant = errors.New("swap: reentrant call rejected")
ErrShortInput = errors.New("swap: input shorter than 4-byte selector")
ErrBadArgs = errors.New("swap: malformed call arguments")
ErrUnknownSelector = errors.New("swap: unknown function selector")
ErrNoBlockContext = errors.New("swap: block context unavailable")
ErrOutOfGas = errors.New("swap: out of gas")
ErrZeroHashlock = errors.New("swap: hashlock must be non-zero")
ErrZeroRecipient = errors.New("swap: recipient must be non-zero")
ErrZeroRefund = errors.New("swap: refund address must be non-zero")
ErrDustAmount = errors.New("swap: amount below MinSwapAmount")
ErrTimeoutBounds = errors.New("swap: timeout outside [T0+MinTimeout, T0+MaxTimeout]")
ErrSwapExists = errors.New("swap: swapId already in use")
ErrNativeUnsupported = errors.New("swap: native-LUX locks unsupported until the call-value seam lands; use a wrapped/ERC-20 asset")
ErrNotLocked = errors.New("swap: swap is not in the Locked state")
ErrExpired = errors.New("swap: timeout passed; claim window closed (refund instead)")
ErrNotExpired = errors.New("swap: timeout not yet reached; cannot refund")
ErrBadPreimage = errors.New("swap: SHA256(preimage) != hashlock or preimage length != 32")
ErrReserveUnderflow = errors.New("swap: reserve underflow (conservation invariant breach)")
ErrVaultUnavailable = errors.New("swap: ERC-20 vault capability unavailable on this StateDB")
ErrTransferFailed = errors.New("swap: ERC-20 transfer reverted or returned false")
ErrDeltaMismatch = errors.New("swap: observed inbound balance delta != requested amount")
)
// SwapContract is the LP-90A0 HTLC precompile. It holds NO mutable Go state; every
// swap field lives in the EVM trie under swapAddr (see state.go). The zero value is
// ready to use.
type SwapContract struct{}
// Run dispatches by 4-byte selector. Views (getSwap, getPreimage) are permitted in
// read-only calls; the three state transitions (lock, claim, refund) are not, and
// each runs under a global non-reentrant guard so the transferFrom-delta
// measurement and the status flip cannot be corrupted by a malicious token's
// callback (ERC-777-style tokensReceived/tokensToSend).
func (c *SwapContract) Run(
accessibleState contract.AccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) (ret []byte, remainingGas uint64, err error) {
if len(input) < contract.SelectorLen {
return nil, suppliedGas, ErrShortInput
}
if accessibleState.GetBlockContext() == nil {
return nil, suppliedGas, ErrNoBlockContext
}
selector := binary.BigEndian.Uint32(input[:contract.SelectorLen])
data := input[contract.SelectorLen:]
switch selector {
case selGetSwap:
return c.runGetSwap(accessibleState, data, suppliedGas)
case selGetPreimage:
return c.runGetPreimage(accessibleState, data, suppliedGas)
case selLock, selClaim, selRefund:
if readOnly {
return nil, suppliedGas, ErrReadOnly
}
db := accessibleState.GetStateDB()
if !enterGuard(db) {
return nil, suppliedGas, ErrReentrant
}
defer exitGuard(db)
switch selector {
case selLock:
return c.runLock(accessibleState, caller, data, suppliedGas)
case selClaim:
return c.runClaim(accessibleState, caller, data, suppliedGas)
default: // selRefund
return c.runRefund(accessibleState, caller, data, suppliedGas)
}
default:
return nil, suppliedGas, fmt.Errorf("%w: %#08x", ErrUnknownSelector, selector)
}
}
// ---------------------------------------------------------------------------
// lock(bytes32 hashlock, address recipient, address refund, address asset,
// uint256 amount, uint64 timeout) -> bytes32 swapId
// ---------------------------------------------------------------------------
func (c *SwapContract) runLock(
st contract.AccessibleState,
caller common.Address,
data []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasLock {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasLock
// 6 static ABI words.
if len(data) != 6*32 {
return nil, gas, fmt.Errorf("%w: lock expects 192 bytes, got %d", ErrBadArgs, len(data))
}
var hashlock common.Hash
copy(hashlock[:], data[0:32])
recipient := wordAddress(data[32:64])
refund := wordAddress(data[64:96])
asset := wordAddress(data[96:128])
amount := new(big.Int).SetBytes(data[128:160])
timeout, ok := wordUint64(data[160:192])
if !ok {
return nil, gas, fmt.Errorf("%w: timeout exceeds uint64", ErrBadArgs)
}
// (2) non-zero hashlock, recipient, refund.
if hashlock == (common.Hash{}) {
return nil, gas, ErrZeroHashlock
}
if recipient == (common.Address{}) {
return nil, gas, ErrZeroRecipient
}
if refund == (common.Address{}) {
return nil, gas, ErrZeroRefund
}
// (3) dust guard.
if amount.Cmp(MinSwapAmount) < 0 {
return nil, gas, ErrDustAmount
}
// (4) absolute timeout bounds.
t0 := st.GetBlockContext().Timestamp()
if timeout < t0+MinTimeout || timeout > t0+MaxTimeout {
return nil, gas, fmt.Errorf("%w: T0=%d timeout=%d", ErrTimeoutBounds, t0, timeout)
}
// Native is gated until the call-value seam lands — NEVER minted.
if asset == (common.Address{}) {
return nil, gas, ErrNativeUnsupported
}
db := st.GetStateDB()
// swapId binds every term plus caller and the monotonic nonce, so identical
// terms locked twice yield distinct ids and cross-caller collisions are
// impossible.
nonce := loadNonce(db)
swapId := computeSwapID(hashlock, recipient, refund, asset, amount, timeout, caller, nonce)
// (6, first half) no collision.
if loadStatus(db, swapId) != StatusNone {
return nil, gas, ErrSwapExists
}
// (5) Move funds IN via the transferFrom-delta idiom and REQUIRE the observed
// inbound delta to equal the requested amount exactly: an HTLC must lock the
// precise agreed amount the counterparty verified, so a fee-on-transfer /
// short-delivering token is rejected rather than silently locking less.
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
delta, err := pullExact(vault, asset, caller, swapAddr, amount)
if err != nil {
return nil, gas, err
}
// (6, second half) persist the swap, bump the per-asset reserve by the OBSERVED
// delta, advance the nonce, emit Locked.
storeStatus(db, swapId, StatusLocked)
storeHash(db, swapId, fHashlock, hashlock)
storeAddress(db, swapId, fRecipient, recipient)
storeAddress(db, swapId, fRefund, refund)
storeAddress(db, swapId, fAsset, asset)
storeAmount(db, swapId, fAmount, amount)
storeTimeout(db, swapId, timeout)
addReserve(db, asset, delta)
storeNonce(db, nonce+1)
emitLocked(db, swapId, hashlock, recipient, refund, asset, amount, timeout)
return swapId[:], gas, nil
}
// ---------------------------------------------------------------------------
// claim(bytes32 swapId, bytes32 preimage) -> bool
// ---------------------------------------------------------------------------
func (c *SwapContract) runClaim(
st contract.AccessibleState,
caller common.Address,
data []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasClaim {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasClaim
if len(data) != 2*32 {
return nil, gas, fmt.Errorf("%w: claim expects 64 bytes, got %d", ErrBadArgs, len(data))
}
var swapId common.Hash
copy(swapId[:], data[0:32])
var preimage [preimageLen]byte
copy(preimage[:], data[32:64])
db := st.GetStateDB()
// (1) must be Locked.
if loadStatus(db, swapId) != StatusLocked {
return nil, gas, ErrNotLocked
}
hashlock := loadHash(db, swapId, fHashlock)
recipient := loadAddress(db, swapId, fRecipient)
asset := loadAddress(db, swapId, fAsset)
amount := loadAmount(db, swapId, fAmount)
timeout := loadTimeout(db, swapId)
// (2) not expired: claim requires T0 < timeout (disjoint from refund).
if st.GetBlockContext().Timestamp() >= timeout {
return nil, gas, ErrExpired
}
// (3) SHA256(preimage) == hashlock (preimage length is fixed by the bytes32 arg).
if !preimageMatches(preimage, hashlock) {
return nil, gas, ErrBadPreimage
}
// EFFECTS BEFORE INTERACTION: flip status, debit the reserve, publish the
// secret — all before the external pay-out, so a malicious token's callback
// re-entering claim/refund on this swap sees a non-Locked status and is
// rejected (no double-spend). The global guard already blocks reentry; this
// ordering is defence in depth.
if !subReserve(db, asset, amount) {
return nil, gas, ErrReserveUnderflow
}
storeStatus(db, swapId, StatusClaimed)
storeHash(db, swapId, fPreimage, common.BytesToHash(preimage[:]))
storePreimageIdx(db, hashlock, preimage)
emitClaimed(db, swapId, hashlock, preimage, recipient, amount)
// INTERACTION: pay the STORED recipient (caller-agnostic — a watchtower may
// submit). On failure the precompile returns an error and the EVM reverts the
// whole frame, including every effect above.
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
if err := pushOut(vault, asset, recipient, amount); err != nil {
return nil, gas, err
}
return boolWord(true), gas, nil
}
// ---------------------------------------------------------------------------
// refund(bytes32 swapId) -> bool
// ---------------------------------------------------------------------------
func (c *SwapContract) runRefund(
st contract.AccessibleState,
caller common.Address,
data []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasRefund {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasRefund
if len(data) != 32 {
return nil, gas, fmt.Errorf("%w: refund expects 32 bytes, got %d", ErrBadArgs, len(data))
}
var swapId common.Hash
copy(swapId[:], data[0:32])
db := st.GetStateDB()
// (1) must be Locked.
if loadStatus(db, swapId) != StatusLocked {
return nil, gas, ErrNotLocked
}
refund := loadAddress(db, swapId, fRefund)
asset := loadAddress(db, swapId, fAsset)
amount := loadAmount(db, swapId, fAmount)
timeout := loadTimeout(db, swapId)
// (2) expired: refund requires T0 >= timeout (disjoint from claim).
if st.GetBlockContext().Timestamp() < timeout {
return nil, gas, ErrNotExpired
}
// EFFECTS BEFORE INTERACTION (see runClaim).
if !subReserve(db, asset, amount) {
return nil, gas, ErrReserveUnderflow
}
storeStatus(db, swapId, StatusRefunded)
emitRefunded(db, swapId, refund, amount)
// INTERACTION: pay the STORED refund address.
vault, ok := db.(erc20Vault)
if !ok {
return nil, gas, ErrVaultUnavailable
}
if err := pushOut(vault, asset, refund, amount); err != nil {
return nil, gas, err
}
return boolWord(true), gas, nil
}
// ---------------------------------------------------------------------------
// Views
// ---------------------------------------------------------------------------
// runGetSwap returns the ABI-encoded swap tuple:
//
// (uint8 status, bytes32 hashlock, address recipient, address refund,
// address asset, uint256 amount, uint64 timeout, bytes32 preimage)
//
// as 8 left-padded 32-byte words. A None swap returns an all-zero tuple.
func (c *SwapContract) runGetSwap(
st contract.AccessibleState,
data []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasGetSwap {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasGetSwap
if len(data) != 32 {
return nil, gas, fmt.Errorf("%w: getSwap expects 32 bytes, got %d", ErrBadArgs, len(data))
}
var swapId common.Hash
copy(swapId[:], data[0:32])
db := st.GetStateDB()
out := make([]byte, 0, 8*32)
out = append(out, uint8Word(loadStatus(db, swapId))...)
out = append(out, loadHash(db, swapId, fHashlock).Bytes()...)
out = append(out, addressWord(loadAddress(db, swapId, fRecipient))...)
out = append(out, addressWord(loadAddress(db, swapId, fRefund))...)
out = append(out, addressWord(loadAddress(db, swapId, fAsset))...)
out = append(out, common.BigToHash(loadAmount(db, swapId, fAmount)).Bytes()...)
out = append(out, uint64Word(loadTimeout(db, swapId))...)
out = append(out, loadHash(db, swapId, fPreimage).Bytes()...)
return out, gas, nil
}
// runGetPreimage returns the revealed preimage for a hashlock (the cross-leg
// secret relay), or bytes32(0) if no swap with that hashlock has been claimed.
func (c *SwapContract) runGetPreimage(
st contract.AccessibleState,
data []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasGetPreimage {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasGetPreimage
if len(data) != 32 {
return nil, gas, fmt.Errorf("%w: getPreimage expects 32 bytes, got %d", ErrBadArgs, len(data))
}
var hashlock common.Hash
copy(hashlock[:], data[0:32])
pre := loadPreimageIdx(st.GetStateDB(), hashlock)
return pre[:], gas, nil
}
+551
View File
@@ -0,0 +1,551 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"crypto/sha256"
"errors"
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/stretchr/testify/require"
)
var errTokenReverted = errors.New("mock token: transfer reverted")
// Test fixtures.
var (
maker = common.HexToAddress("0x1111111111111111111111111111111111111111")
user = common.HexToAddress("0x2222222222222222222222222222222222222222")
watcher = common.HexToAddress("0x3333333333333333333333333333333333333333")
usdc = common.HexToAddress("0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48")
wbtc = common.HexToAddress("0x2260FAC5E5542A773AA44FBCFEDF7C193BC2C599")
)
const (
t0 = uint64(1_700_000_000)
timeout = t0 + 3600 // 1h, within [MinTimeout, MaxTimeout]
)
func preimageOf(b byte) [32]byte {
var p [32]byte
for i := range p {
p[i] = b
}
return p
}
func hashOf(p [32]byte) common.Hash {
s := sha256.Sum256(p[:])
return common.BytesToHash(s[:])
}
// ---------------------------------------------------------------------------
// Selector authority: derived selectors must equal the canonical values.
// ---------------------------------------------------------------------------
func TestSelectorsAreAuthoritative(t *testing.T) {
require.Equal(t, uint32(0x4da2c728), selLock)
require.Equal(t, uint32(0x84cc9dfb), selClaim)
require.Equal(t, uint32(0x7249fbb6), selRefund)
require.Equal(t, uint32(0x3da0e66e), selGetSwap)
require.Equal(t, uint32(0x0f622b04), selGetPreimage)
}
// ---------------------------------------------------------------------------
// Happy path: lock -> claim. Recipient is paid the stored amount, the secret is
// relayed, reserve and vault balance both fall by exactly the amount.
// ---------------------------------------------------------------------------
func TestLockThenClaim(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(1_000_000)
e.db().fund(usdc, maker, amt)
pre := preimageOf(0xAB)
h := hashOf(pre)
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
require.NotEqual(t, common.Hash{}, id)
// Funds left the maker, sit in the vault, reserve credited.
eqBig(t, big.NewInt(0), e.db().bal(usdc, maker))
eqBig(t, amt, e.db().bal(usdc, swapAddr))
eqBig(t, amt, e.reserve(usdc))
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
// A watchtower (not the recipient) submits the claim — caller-agnostic.
ret, err := e.claim(watcher, id, pre, false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
// Recipient paid exactly amount; vault drained; reserve zeroed.
eqBig(t, amt, e.db().bal(usdc, user))
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
eqBig(t, big.NewInt(0), e.reserve(usdc))
require.Equal(t, StatusClaimed, loadStatus(e.db(), id))
// Secret relayed on-chain for the counterparty leg.
got, err := e.getPreimage(h, true)
require.NoError(t, err)
require.Equal(t, pre, got)
}
// ---------------------------------------------------------------------------
// Refund path: lock -> (timeout passes) -> refund to the stored refund address.
// ---------------------------------------------------------------------------
func TestLockThenRefundAfterTimeout(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(500)
e.db().fund(usdc, maker, amt)
h := hashOf(preimageOf(0x01))
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
// Cannot refund before the timeout.
_, err = e.refund(maker, id, false)
require.ErrorIs(t, err, ErrNotExpired)
// At/after timeout, refund pays the stored refund address (maker).
e.setNow(timeout)
ret, err := e.refund(watcher, id, false)
require.NoError(t, err)
require.Equal(t, boolWord(true), ret)
eqBig(t, amt, e.db().bal(usdc, maker))
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
eqBig(t, big.NewInt(0), e.reserve(usdc))
require.Equal(t, StatusRefunded, loadStatus(e.db(), id))
}
// ---------------------------------------------------------------------------
// Wrong preimage is rejected with NO state change and NO payout.
// ---------------------------------------------------------------------------
func TestClaimWrongPreimageRejected(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(777)
e.db().fund(usdc, maker, amt)
h := hashOf(preimageOf(0x42))
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
_, err = e.claim(user, id, preimageOf(0x43), false) // wrong secret
require.ErrorIs(t, err, ErrBadPreimage)
// Nothing moved; swap still claimable by the right secret.
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
eqBig(t, amt, e.db().bal(usdc, swapAddr))
eqBig(t, amt, e.reserve(usdc))
eqBig(t, big.NewInt(0), e.db().bal(usdc, user))
got, err := e.getPreimage(h, true)
require.NoError(t, err)
require.Equal(t, [32]byte{}, got) // no secret leaked
}
// ---------------------------------------------------------------------------
// Disjoint boundary: claim requires T0 < timeout, refund requires T0 >= timeout.
// A swap is never simultaneously claimable and refundable.
// ---------------------------------------------------------------------------
func TestClaimRefundBoundaryDisjoint(t *testing.T) {
pre := preimageOf(0x55)
h := hashOf(pre)
amt := big.NewInt(100)
t.Run("just_before_timeout: claim ok, refund rejected", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
e.setNow(timeout - 1)
_, err = e.refund(maker, id, false)
require.ErrorIs(t, err, ErrNotExpired)
_, err = e.claim(user, id, pre, false)
require.NoError(t, err)
})
t.Run("at_timeout: claim rejected, refund ok", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
e.setNow(timeout) // exactly at timeout
_, err = e.claim(user, id, pre, false)
require.ErrorIs(t, err, ErrExpired)
_, err = e.refund(maker, id, false)
require.NoError(t, err)
})
}
// ---------------------------------------------------------------------------
// Double-claim and double-refund are rejected (status flips once).
// ---------------------------------------------------------------------------
func TestDoubleSpendRejected(t *testing.T) {
pre := preimageOf(0x09)
h := hashOf(pre)
amt := big.NewInt(2024)
t.Run("double_claim", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
_, err = e.claim(user, id, pre, false)
require.NoError(t, err)
_, err = e.claim(user, id, pre, false)
require.ErrorIs(t, err, ErrNotLocked)
// Recipient paid once, not twice.
eqBig(t, amt, e.db().bal(usdc, user))
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
})
t.Run("double_refund", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
e.setNow(timeout)
_, err = e.refund(maker, id, false)
require.NoError(t, err)
_, err = e.refund(maker, id, false)
require.ErrorIs(t, err, ErrNotLocked)
eqBig(t, amt, e.db().bal(usdc, maker))
})
t.Run("claim_then_refund_blocked", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
_, err = e.claim(user, id, pre, false)
require.NoError(t, err)
e.setNow(timeout) // even after expiry, a claimed swap cannot refund
_, err = e.refund(maker, id, false)
require.ErrorIs(t, err, ErrNotLocked)
})
}
// ---------------------------------------------------------------------------
// claim/refund on a nonexistent swap, and various lock validations.
// ---------------------------------------------------------------------------
func TestLockValidation(t *testing.T) {
amt := big.NewInt(1000)
h := hashOf(preimageOf(0x11))
cases := []struct {
name string
hashlock common.Hash
recip common.Address
ref common.Address
asset common.Address
amount *big.Int
timeout uint64
wantErr error
}{
{"zero_hashlock", common.Hash{}, user, maker, usdc, amt, timeout, ErrZeroHashlock},
{"zero_recipient", h, common.Address{}, maker, usdc, amt, timeout, ErrZeroRecipient},
{"zero_refund", h, user, common.Address{}, usdc, amt, timeout, ErrZeroRefund},
{"dust", h, user, maker, usdc, big.NewInt(0), timeout, ErrDustAmount},
{"timeout_too_soon", h, user, maker, usdc, amt, t0 + MinTimeout - 1, ErrTimeoutBounds},
{"timeout_too_late", h, user, maker, usdc, amt, t0 + MaxTimeout + 1, ErrTimeoutBounds},
{"native_gated", h, user, maker, common.Address{}, amt, timeout, ErrNativeUnsupported},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
_, _, err := e.lock(maker, tc.hashlock, tc.recip, tc.ref, tc.asset, tc.amount, tc.timeout, false)
require.ErrorIs(t, err, tc.wantErr)
// No swap created on any rejection; no funds taken.
eqBig(t, amt, e.db().bal(usdc, maker))
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
eqBig(t, big.NewInt(0), e.reserve(usdc))
})
}
}
func TestClaimRefundNonexistent(t *testing.T) {
e := newEnv(t0)
var id common.Hash
id[0] = 0xDE
_, err := e.claim(user, id, preimageOf(1), false)
require.ErrorIs(t, err, ErrNotLocked)
_, err = e.refund(user, id, false)
require.ErrorIs(t, err, ErrNotLocked)
}
// ---------------------------------------------------------------------------
// Native (asset == 0) is gated, NOT minted: no reserve, no vault balance.
// ---------------------------------------------------------------------------
func TestNativeLockNotMinted(t *testing.T) {
e := newEnv(t0)
h := hashOf(preimageOf(0x77))
_, _, err := e.lock(maker, h, user, maker, common.Address{}, big.NewInt(1e9), timeout, false)
require.ErrorIs(t, err, ErrNativeUnsupported)
eqBig(t, big.NewInt(0), e.reserve(common.Address{}))
eqBig(t, big.NewInt(0), e.db().bal(common.Address{}, swapAddr))
}
// ---------------------------------------------------------------------------
// Fee-on-transfer / short-delivery is rejected at lock (delta != amount), and a
// reverting token aborts the lock with no swap created.
// ---------------------------------------------------------------------------
func TestLockFundsInSafety(t *testing.T) {
amt := big.NewInt(1_000_000)
h := hashOf(preimageOf(0x88))
t.Run("fee_on_transfer_rejected", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
e.db().feeBps[usdc] = 30 // delivers 0.3% less than requested
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.ErrorIs(t, err, ErrDeltaMismatch)
eqBig(t, big.NewInt(0), e.reserve(usdc))
})
t.Run("reverting_token_aborts", func(t *testing.T) {
e := newEnv(t0)
e.db().fund(usdc, maker, amt)
e.db().failTransfer[usdc] = true
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.ErrorIs(t, err, ErrTransferFailed)
eqBig(t, big.NewInt(0), e.reserve(usdc))
eqBig(t, amt, e.db().bal(usdc, maker))
})
}
// ---------------------------------------------------------------------------
// read-only (static) calls: state transitions rejected, views allowed.
// ---------------------------------------------------------------------------
func TestReadOnlyGating(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(1000)
e.db().fund(usdc, maker, amt)
pre := preimageOf(0x21)
h := hashOf(pre)
_, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, true)
require.ErrorIs(t, err, ErrReadOnly)
// Lock for real, then prove claim/refund are read-only gated and views are not.
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
_, err = e.claim(user, id, pre, true)
require.ErrorIs(t, err, ErrReadOnly)
_, err = e.refund(maker, id, true)
require.ErrorIs(t, err, ErrReadOnly)
_, err = e.getSwap(id, true)
require.NoError(t, err)
_, err = e.getPreimage(h, true)
require.NoError(t, err)
}
// ---------------------------------------------------------------------------
// Reentrancy: a token that reenters lock during transferFrom is rejected by the
// global guard; the outer lock still completes correctly.
// ---------------------------------------------------------------------------
func TestReentrancyGuard(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(1000)
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(2)))
h := hashOf(preimageOf(0x31))
var reentryErr error
e.db().reenter = func() {
// Attempt a nested lock during the outer lock's transferFrom.
_, _, reentryErr = e.lock(maker, hashOf(preimageOf(0x32)), user, maker, usdc, amt, timeout, false)
}
id, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
require.ErrorIs(t, reentryErr, ErrReentrant)
// Exactly one swap funded; the reentrant attempt moved nothing extra.
eqBig(t, amt, e.db().bal(usdc, swapAddr))
eqBig(t, amt, e.reserve(usdc))
require.Equal(t, StatusLocked, loadStatus(e.db(), id))
}
// ---------------------------------------------------------------------------
// Conservation invariant across a mixed sequence: at every step
// balanceOf(swapAddr) == reserve[asset], and the global pay-out never exceeds the
// global lock-in (no extraction).
// ---------------------------------------------------------------------------
func TestConservationAcrossSequence(t *testing.T) {
e := newEnv(t0)
totalIn := big.NewInt(0)
// Generous funding for many makers.
e.db().fund(usdc, maker, big.NewInt(1_000_000))
e.db().fund(wbtc, maker, big.NewInt(1_000_000))
check := func() {
// Per-asset: real vault balance equals the precompile's own reserve ledger.
eqBig(t, e.reserve(usdc), e.db().bal(usdc, swapAddr))
eqBig(t, e.reserve(wbtc), e.db().bal(wbtc, swapAddr))
}
lockOne := func(asset common.Address, amt *big.Int, secret byte, to uint64) common.Hash {
h := hashOf(preimageOf(secret))
id, _, err := e.lock(maker, h, user, maker, asset, amt, to, false)
require.NoError(t, err)
totalIn.Add(totalIn, amt)
check()
return id
}
a := lockOne(usdc, big.NewInt(100), 0xA1, timeout)
b := lockOne(usdc, big.NewInt(250), 0xA2, timeout)
c := lockOne(wbtc, big.NewInt(70), 0xA3, timeout)
// Claim a, refund c (after timeout), leave b live.
_, err := e.claim(user, a, preimageOf(0xA1), false)
require.NoError(t, err)
check()
e.setNow(timeout)
_, err = e.refund(maker, c, false)
require.NoError(t, err)
check()
// b is still locked: its asset's vault balance equals its amount.
require.Equal(t, StatusLocked, loadStatus(e.db(), b))
eqBig(t, big.NewInt(250), e.reserve(usdc))
eqBig(t, big.NewInt(250), e.db().bal(usdc, swapAddr))
// No extraction: total paid out (to user + back to maker) never exceeds
// total locked in. Sum across both assets of (out) = in - stillLocked.
stillLocked := new(big.Int).Add(e.reserve(usdc), e.reserve(wbtc))
paidOut := new(big.Int).Sub(totalIn, stillLocked)
// user received 100 (claim a); maker received 70 back (refund c) = 170.
eqBig(t, big.NewInt(170), paidOut)
require.True(t, paidOut.Cmp(totalIn) <= 0)
}
// ---------------------------------------------------------------------------
// Two-leg atomic scenario: claiming leg A publishes the secret s; the maker's
// watchtower reads s via getPreimage and uses it to claim leg B (same hashlock).
// This is the cross-leg secret relay that makes the swap atomic.
// ---------------------------------------------------------------------------
func TestTwoLegAtomicScenario(t *testing.T) {
e := newEnv(t0)
pre := preimageOf(0xCC)
h := hashOf(pre)
// Leg A: maker locks USDC, recipient = user (the secret holder).
amtA := big.NewInt(10_000)
e.db().fund(usdc, maker, amtA)
legA, _, err := e.lock(maker, h, user, maker, usdc, amtA, timeout-10, false) // earlier timeout
require.NoError(t, err)
// Leg B: user locks WBTC, recipient = maker, SAME hashlock, LATER timeout.
amtB := big.NewInt(40)
e.db().fund(wbtc, user, amtB)
legB, _, err := e.lock(user, h, maker, user, wbtc, amtB, timeout, false)
require.NoError(t, err)
// User claims leg A with the secret -> receives USDC, publishes s.
_, err = e.claim(user, legA, pre, false)
require.NoError(t, err)
eqBig(t, amtA, e.db().bal(usdc, user))
// Maker's watchtower reads the published secret from the chain by hashlock...
relayed, err := e.getPreimage(h, true)
require.NoError(t, err)
require.Equal(t, pre, relayed)
// ...and uses it to claim leg B WITHOUT ever having been told s out-of-band.
_, err = e.claim(maker, legB, relayed, false)
require.NoError(t, err)
eqBig(t, amtB, e.db().bal(wbtc, maker))
// Both legs settled to the agreed recipients; both vaults empty; conserved.
require.Equal(t, StatusClaimed, loadStatus(e.db(), legA))
require.Equal(t, StatusClaimed, loadStatus(e.db(), legB))
eqBig(t, big.NewInt(0), e.db().bal(usdc, swapAddr))
eqBig(t, big.NewInt(0), e.db().bal(wbtc, swapAddr))
eqBig(t, big.NewInt(0), e.reserve(usdc))
eqBig(t, big.NewInt(0), e.reserve(wbtc))
}
// ---------------------------------------------------------------------------
// Distinct callers / identical terms produce distinct swapIds (nonce binding),
// and getSwap reflects stored fields.
// ---------------------------------------------------------------------------
func TestSwapIDUniquenessAndView(t *testing.T) {
e := newEnv(t0)
amt := big.NewInt(1000)
e.db().fund(usdc, maker, new(big.Int).Mul(amt, big.NewInt(2)))
h := hashOf(preimageOf(0x44))
id1, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
id2, _, err := e.lock(maker, h, user, maker, usdc, amt, timeout, false)
require.NoError(t, err)
require.NotEqual(t, id1, id2, "identical terms must yield distinct ids via the nonce")
out, err := e.getSwap(id1, true)
require.NoError(t, err)
require.Len(t, out, 8*32)
require.Equal(t, byte(StatusLocked), out[31]) // status word
require.Equal(t, h, common.BytesToHash(out[32:64])) // hashlock word
require.Equal(t, user, common.BytesToAddress(out[64:96])) // recipient word
}
// ---------------------------------------------------------------------------
// Out-of-gas and malformed input.
// ---------------------------------------------------------------------------
func TestGasAndMalformedInput(t *testing.T) {
e := newEnv(t0)
h := hashOf(preimageOf(0x66))
// Out of gas on lock.
in := selBytes(selLock)
in = append(in, h[:]...)
in = append(in, addrArg(user)...)
in = append(in, addrArg(maker)...)
in = append(in, addrArg(usdc)...)
in = append(in, amountArg(big.NewInt(1000))...)
in = append(in, u64Arg(timeout)...)
_, _, err := e.c.Run(e.st, maker, swapAddr, in, GasLock-1, false)
require.ErrorIs(t, err, ErrOutOfGas)
// Short input (< selector).
_, _, err = e.c.Run(e.st, maker, swapAddr, []byte{0x01, 0x02}, e.gas, false)
require.ErrorIs(t, err, ErrShortInput)
// Unknown selector.
_, _, err = e.c.Run(e.st, maker, swapAddr, []byte{0xff, 0xff, 0xff, 0xff}, e.gas, false)
require.ErrorIs(t, err, ErrUnknownSelector)
// Malformed lock args (wrong length).
bad := append(selBytes(selLock), 0x00)
_, _, err = e.c.Run(e.st, maker, swapAddr, bad, e.gas, false)
require.ErrorIs(t, err, ErrBadArgs)
}
// ---------------------------------------------------------------------------
// Module registration sanity: the precompile is wired at LP-90A0.
// ---------------------------------------------------------------------------
func TestModuleRegistered(t *testing.T) {
require.Equal(t, swapAddr, Module.Address)
require.Equal(t, ConfigKey, Module.ConfigKey)
require.Equal(t, common.HexToAddress("0x00000000000000000000000000000000000090A0"), swapAddr)
}
+59
View File
@@ -0,0 +1,59 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package swap
import (
"fmt"
"math/big"
"github.com/luxfi/geth/common"
)
// erc20Vault is the OPTIONAL capability the host StateDB implements to move ERC-20
// value in and out of the HTLC at swapAddr. It is the exact seam dex/erc20_vault.go
// uses, type-asserted off the StateDB so the core contract.StateDB interface (and
// every mock/adapter that implements it) stays unchanged. A StateDB that does not
// implement it refuses ERC-20 custody cleanly (ErrVaultUnavailable) — it never
// fakes a credit, never mints.
//
// - TokenBalanceOf(token, owner): the owner's current balance of token.
// - TransferTokenFrom(token, from, to, amount): pull amount of token from `from`
// to `to` using the allowance `from` granted (ERC-20 transferFrom). Returns an
// error on a reverted / false-returning transfer (OZ-safe).
// - TransferTokenTo(token, to, amount): push amount of token from swapAddr (the
// precompile self) to `to` (ERC-20 transfer). Same OZ-safe failure semantics.
type erc20Vault interface {
TokenBalanceOf(token, owner common.Address) *big.Int
TransferTokenFrom(token, from, to common.Address, amount *big.Int) error
TransferTokenTo(token, to common.Address, amount *big.Int) error
}
// pullExact pulls `amount` of `asset` from `from` into the vault and verifies the
// OBSERVED inbound balance delta equals `amount` exactly. It measures
// balanceOf(vault)_after balanceOf(vault)_before (OpenZeppelin SafeERC20 style),
// so a fee-on-transfer / rebasing token that delivers a different amount is
// rejected (ErrDeltaMismatch) rather than locking a wrong amount. Returns the
// observed delta (== amount) for the caller to credit to the reserve.
func pullExact(vault erc20Vault, asset, from, to common.Address, amount *big.Int) (*big.Int, error) {
before := vault.TokenBalanceOf(asset, to)
if err := vault.TransferTokenFrom(asset, from, to, amount); err != nil {
return nil, fmt.Errorf("%w: %v", ErrTransferFailed, err)
}
after := vault.TokenBalanceOf(asset, to)
delta := new(big.Int).Sub(after, before)
if delta.Cmp(amount) != 0 {
return nil, fmt.Errorf("%w: requested %s, observed %s", ErrDeltaMismatch, amount, delta)
}
return delta, nil
}
// pushOut pays exactly `amount` of `asset` from the vault to `to`. A reverted /
// false transfer surfaces as ErrTransferFailed; the caller has already debited the
// reserve (effects-before-interaction), so on error the EVM reverts the frame.
func pushOut(vault erc20Vault, asset, to common.Address, amount *big.Int) error {
if err := vault.TransferTokenTo(asset, to, amount); err != nil {
return fmt.Errorf("%w: %v", ErrTransferFailed, err)
}
return nil
}