mirror of
https://github.com/luxfi/precompile.git
synced 2026-07-27 03:33:45 +00:00
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:
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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(ðtypes.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(ðtypes.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(ðtypes.Log{
|
||||
Address: swapAddr,
|
||||
Topics: []common.Hash{refundedEventSig, swapId},
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 commit–reveal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user