Trustless, permissionless cross-chain swaps via Hashed Time-Locked Contracts (HTLC). Users lock funds on the source chain, solvers fulfil on the destination chain, and a single secret preimage atomically unlocks both sides — or both refund after their timelocks.
| Contract | Role |
|---|---|
Train.sol |
Core HTLC vault. Holds all balances in one contract (no clones). User locks, solver locks, redeem, refund, plus a permissionless userLockFor intake. Native ETH + ERC20. |
TrainRouter.sol |
Address-less gasless intake for user locks via Permit2, ERC-2612 permit, and EIP-3009 receiveWithAuthorization. Holds no funds and no governance; forwards into a per-call train bound by the user's signed intent. |
ConstantPayoutCurve.sol |
The single payout curve: returns the full locked amount (no time decay). Pluggable via IPayoutCurve. |
IPayoutCurve.sol |
Curve interface (called via STATICCALL). |
src/interfaces/ |
Minimal vendored IERC3009, ISignatureTransfer (Permit2), and ITrain (the userLockFor ABI the Router calls). |
Toolchain: Solidity 0.8.34, evm_version = cancun, via_ir = true, optimizer runs 1,000,000.
Reentrancy is guarded with OpenZeppelin ReentrancyGuardTransient (EIP-1153 transient storage).
SOURCE CHAIN (Train) DESTINATION CHAIN (Train)
user ──userLock(hashlock, …)──► lock solver ──solverLock(hashlock,…)──► lock
user ──redeemSolver(secret)──► reveals secret
solver ──redeemUser(secret)──► gets funds ◄────────────────── (secret now public)
(or, after timelock, refundUser → refundTo)
hashlock = sha256(abi.encodePacked(secret)) (SHA-256 for cross-chain compatibility; secret is a
uint256). Redeem works anytime while Pending; refunds are timelock-gated (the user lock's
recipient may refund anytime).
For end users who shouldn't pay gas, a relayer submits the user's signed intent to the Router, which pulls the user's tokens and forwards them into Train:
user signs intent ──► relayer calls TrainRouter.forwardWith{Permit,Permit2,Authorization}(user, token, amount, train, callData, nonce, deadline, …)
│ pulls user's ERC20 (Permit2 / 2612 / 3009)
│ forceApprove(train, amount); train.call(callData); forceApprove(train, 0)
▼
arbitrary `train` — here callData = Train.userLockFor(user, …) (lock attributed to `user`)
The Router is target-agnostic and fully abstract: no owner, no stored addresses, and no knowledge
of the target's ABI. The caller ABI-encodes the destination call off-chain as opaque callData; the
Router only pulls funds and forwards that call. The signed intent commits to
(user, train, token, amount, keccak256(callData), nonce, deadline), so the signature fixes exactly
where funds go and what call executes — a relayer can only run the precise call the user authorized.
- Exact-amount approve + forward. The Router pulls the user's tokens,
forceApproves the (untrusted)trainfor exactlyamount, low-level-callscallData(bubbling any revert), then resets the approval to 0.traincan consume at mostamount. - Conservation check. After forwarding, the Router asserts its token balance returned to the
pre-pull value (
ResidualBalanceotherwise) — it never custodies a balance, and a malicious/brokentrainthat doesn't consume the funds reverts the whole tx (the user keeps their tokens). - Fund-safety vs. call-shape. Because the Router doesn't inspect
callData, the "right recipient/curve/amount" guarantee comes entirely from the user's signature overkeccak256(callData); the Router itself only guarantees it forwards exactly that call and custodies nothing. (Here the forwarded call isTrain.userLockFor, which additionally re-measures its ownbalanceOfdelta.) - Intent binding per standard:
- ERC-2612 — a separate EIP-712 intent signature (verified with
SignatureChecker, so EOAs and ERC-1271 smart accounts both work) bindshashIntent(user, train, token, amount, callHash, nonce, deadline). - Permit2 — the intent hash is the
permitWitnessTransferFromwitness (one signature). - EIP-3009 — the intent hash is forced as the nonce (one signature).
- ERC-2612 — a separate EIP-712 intent signature (verified with
- Replay — every intent carries a user-chosen
nonceand adeadline; the Router records its struct hash inconsumedIntentand rejects re-use across all three paths (IntentAlreadyConsumed), and rejects an intent past itsdeadline(IntentExpired). A signed intent therefore executes at most once — vary thenonceto authorize a deliberate repeat of the same call. Train's unique-hashlock check remains as defense-in-depth behind the Router guard.
The Router's EIP-712 domain is ("TrainRouter", "1"). hashIntent, intentDigest, DOMAIN_SEPARATOR,
and WITNESS_TYPE_STRING are exposed for off-chain signers.
These are documented, accepted behaviors — read before integrating.
- Payout-curve trust (caller-supplied).
payoutCurveis only validated forIPayoutCurvesupport at lock creation — there is no on-chain allowlist. A lock creator could set a curve that returns a near-zero payout (sending most of the redeem torefundTo) or reverts (bricking redemption). Mitigation: solvers must only fill locks whosepayoutCurvethey recognize (it's in theUserLockedevent before they commit). OnlyConstantPayoutCurveis intended/shipped. - Hashlock front-run squat. The
userLockskeyspace is global perhashlock. Anyone can occupy a hashlock with a ~1-wei lock, permanently blocking that specific swap (thesenderslot is never cleared). No funds are at risk — the victim re-quotes with a fresh secret. Solver locks are keyed by(hashlock, solver), so nobody can squat another solver's slot — but each solver gets exactly one, permanent slot per hashlock: after a refund (e.g. a botched fill), re-filling the same hashlock requires a different solver address. This is deliberate — it's the retry/double-funding guard (SolverLockAlreadyExists), sized so an RPC that lies about a lock tx can never cost a solver a second escrow. userLockForattribution. Anyone may attribute a lock to anyuserat gas-only cost (the 1-wei minimum is instantly reclaimable, and the lock owner of record is purely attributive — custody keys offrefundTo/recipient, notsender). The only effect is appending to auser's history list. The enumeration getters are scale-safe (windowed storage reads,total = length), so the list can't be inflated into a getter DoS. Intent-signature verification lives in the Router, never in Train.- Fee-on-transfer / rebasing tokens. The direct
userLock/solverLockpaths are FoT-safe — Train credits the measured balance delta. The gasless Router path expects non-FoT tokens (a fee on the user→Router leg revertsInsufficientPulled; the conservation check guarantees the Router custodies nothing but does not by itself guarantee full funding if a fee hits the Router→Train leg). Positive-rebasing tokens are not supported (the delta measurement could over-credit from pooled funds). Use standard ERC20s (e.g. USDC) on the gasless path. - Native-ETH gas stipend. ETH is sent with a 10,000-gas stipend. A contract
recipient/refundTowhosereceive()/fallback needs more will make redeem/refund revert — use an EOA for native-ETH locks. ERC20 transfers are unaffected. - Native ETH is ERC20-only on the gasless path.
userLock/solverLockstaypayablefor native ETH; the Router is ERC20-only (the gasless standards are token signatures). - Tempo gets a dedicated contract, not this one. Tempo has no native gas token —
CALLVALUE/BALANCE/SELFBALANCEalways return 0. Rather than deploy thisTrain.solwith its native-ETH paths sitting unreachable, Tempo deployssrc/tempo/Train.sol: the same core HTLC logic withpayable, the native-ETH branches, anduserLockForremoved entirely (see point 9 for whyuserLockForspecifically isn't needed there). Seescript/tempo/README.md. - pathUSD carries an active TIP-403 blacklist policy. Tempo's fee-fallback TIP-20 (
0x20C0...) is not on the permissive default policy — it's on an admin-controlled blacklist (policy id 2, immediate effect, no appeal process). If a lock'srecipient/refundTogets blocked after creation, both redeem and refund can revert permanently on that lock — Train has no admin sweep. Accepted risk, noted here for awareness; document the current admin identity/governance when it's needed. TrainRouteris not deployed on Tempo at all. Its whole purpose — let a user sign once off-chain and have an unrelated relayer submit and pay for it — is already a native Tempo Transaction feature (calls: Vec<Call>batching +fee_payer_signaturesponsorship), proven working end-to-end on Moderato testnet. DeployingTrainRouteron Tempo would just reintroduce the TIP-1004 permit-vtrap and TIP-403 exposure above for a caller with no reason to exist there — hence nouserLockForeither (its only purpose is lettingTrainRouterattribute a lock to someone other thanmsg.sender). Seescript/tempo/README.mdfor the native gasless flow.
userLock(params, dst, userData, solverData)payable— caller funds + creates a user lock.userLockFor(user, params, dst, userData, solverData)— permissionless, ERC20-only, non-payable; funds pulled frommsg.sender, lock attributed touser. The Router's forwarding target.redeemUser(hashlock, secret)— anyone with the secret; paysrecipient(curve payout), excess torefundTo.refundUser(hashlock)—recipientanytime, others after timelock; returns full amount torefundTo.
solverLock(params, dst, data)payable— supports all (token, rewardToken) ETH/ERC20 combinations. Locks are keyed by(hashlock, msg.sender): at most one solver lock per solver per hashlock, ever — a repeat call revertsSolverLockAlreadyExistsbefore pulling funds, so a blind retry (unreliable/malicious RPC) can't double-fund the same swap. Many different solvers may still lock under one hashlock. The guard never lifts (not even after refund); a deliberate re-fill needs a different solver address.redeemSolver(hashlock, solver, secret)— reward →rewardRecipientbeforerewardTimelock, else → the redeemer (relayer bounty).refundSolver(hashlock, solver)— after timelock; returns amount + reward torefundTo.
getUserLock(hashlock) → UserLock·getSolverLock(hashlock, solver) → SolverLock— the latter doubles as a solver's idempotency probe (sender == 0⇒ that solver never locked here; cross-check on several RPCs before retrying). Discovery of other solvers' locks is event-driven (SolverLocked).getUserLockHashes(user, offset, limit) → (bytes32[], total)andgetUserLocks(user, offset, limit) → (UserLock[], total)— windowed reads (no whole-array copy);totalis the user's lock count. Filter byUserLock.statusoff-chain (the on-chain status filter was removed so these never hit a node'seth_callgas cap regardless of list size).
forwardWithPermit(user, token, amount, train, callData, nonce, deadline, permitData, intentSig)forwardWithPermit2(user, token, amount, train, callData, nonce, deadline, permit2, permit, sig)forwardWithAuthorization(user, token, amount, train, callData, nonce, deadline, auth)- Each pulls
amountoftokengaslessly and forwardscallData(e.g. an encodedTrain.userLockFor) totrain, then emitsIntentForwarded(user, train, callHash, relayer, token, amount). The intent'snonce+deadlinegive single-use replay protection (consumedIntent) across all three paths. - views:
hashIntent(user, train, token, amount, callHash, nonce, deadline),intentDigest(...),consumedIntent(intentHash),DOMAIN_SEPARATOR(),WITNESS_TYPE_STRING()
A lock may set payoutCurve + payoutCurveData; on redeem the recipient gets computePayout(...) and
any remainder goes to refundTo (refunds always return the full amount). The curve is STATICCALLed
(external view), so it cannot mutate Train state. Curves must implement EIP-165; Train validates a
configured payoutCurve with OpenZeppelin's ERC165Checker before trusting it. The single shipped
curve, ConstantPayoutCurve, returns the full amount (no decay) — making the mechanism an
explicit no-op while keeping extensibility. See trust assumption #1 above on accepting arbitrary curves.
interface IPayoutCurve is IERC165 {
function computePayout(uint256 amount, uint48 startTime, uint48 currentTime, bytes calldata config)
external view returns (uint256 payout); // must satisfy 0 < payout <= amount
// supportsInterface(bytes4) inherited from IERC165; Train probes it via ERC165Checker.
}Both lock structs are packing-aware (see the per-field slot annotations in Train.sol):
UserLock— slots 0–7:secret,amount(full slots);{sender,timelock,startTime}pack into one slot;{status,recipient}into the next;refundTo,token,payoutCurveone each;payoutCurveDatadynamic.SolverLock— slots 0–10:secret,amount,reward;{sender,timelock,rewardTimelock}pack;{startTime,recipient,status}pack;rewardRecipient,refundTo,token,rewardToken,payoutCurveone each;payoutCurveDatadynamic.
lock.amount/lock.reward store the measured received amount (fee-on-transfer safe); the
UserLocked/SolverLocked events log the measured amount and the lock's payoutCurve
(address(0) when none), so indexers/solvers can see the curve without an extra RPC call.
Train: ZeroAmount, ZeroAddress, InvalidUser, NativeNotSupported, LockNotFound,
HashlockMismatch, LockNotPending, InvalidTimelock, InvalidRewardTimelock, SwapAlreadyExists,
SolverLockAlreadyExists, TransferFailed, MsgValueMismatch, RefundNotAllowed, InvalidToken,
QuoteExpired, InvalidPayoutCurve, InvalidPayout.
TrainRouter: InvalidUser, NativeNotSupported, InvalidIntentSignature, Permit2Mismatch,
InsufficientPulled, ResidualBalance.
forge build
forge test # unit + fuzz (fork tests self-skip)
forge test --fork-url $MAINNET_RPC_URL --match-path test/TrainRouterFork.t.sol # real Permit2 + USDCTest layout: Train.t.sol (+ Train.fuzz.t.sol) core unit/fuzz; TrainRouter.t.sol (mock Permit2/3009)
and TrainRouterFork.t.sol (real Permit2 + USDC); UserLockFor.t.sol; PayoutCurve.t.sol
(ConstantPayoutCurve + curve plumbing and the non-constant excess split); TrainEdgeCases.t.sol (revert
paths, zero-address guards, scale-safe getters, double-settle, dust, bounds); TrainInvariants.t.sol and
Reentrancy.t.sol. The protocol-wide invariants live once in test/invariant/ and run under three engines —
Foundry (FoundryInvariant), Echidna, and Medusa.
Medusa/Echidna read a cached Slither result (
slither_results.json). Keep that file (or runmedusa fuzz --use-slither-force) — without a cache Medusa launches a live Slither pass that is very slow / hangs on thisvia_irproject.
script/Deploy.s.sol deploys ConstantPayoutCurve + Train + TrainRouter and prints the addresses.
For a complete Sepolia deploy-and-exercise walkthrough (env vars, faucets, the gasless flows against
real Permit2/USDC, refunds, the EIP-7702 signer caveat, and --verify), see
script/sepolia/README.md.
forge script script/Deploy.s.sol --rpc-url $RPC_URL --broadcast \
--verify --verifier etherscan --etherscan-api-key $ETHERSCAN_API_KEYTarget an EVM with Cancun support (transient storage). Well-known addresses used by the testnet
scripts: Permit2 0x000000000022D473030F116dDEE9F6B43aC78BA3 (canonical), Sepolia USDC
0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238.
script/DeployDeterministic.s.sol deploys the three contracts via CREATE2 through the Arachnid
factory (0x4e59b44847b379578588920cA78FbF26c0B4956C), so the addresses depend only on the salt and
the initcode — not on the deployer key or nonce. Every chain gets the same three addresses, the
script is idempotent (already-deployed contracts are skipped), and deployment is permissionless:
anyone re-running it can only land the exact same bytecode at the same address.
script/deploy-testnets.ps1 orchestrates it across 7 testnets — Sepolia, Arbitrum Sepolia,
Base Sepolia, OP Sepolia, BSC Testnet, Linea Sepolia, Monad Testnet — using the public
[rpc_endpoints] in foundry.toml, with explorer verification (Etherscan API v2 everywhere,
including MonadScan for Monad testnet):
# fund the deployer key on all target chains first, then:
.\script\deploy-testnets.ps1 -DryRun # simulate everywhere, broadcast nothing
.\script\deploy-testnets.ps1 -Chains sepolia # pilot one chain
.\script\deploy-testnets.ps1 # deploy + verify on all 7Salt policy: addresses derive from keccak256('train.protocol.v2') (override with CREATE2_SALT).
Same salt + same commit + same solc/settings ⇒ same address; any source or compiler-settings
change alters the initcode and therefore the address — bump the salt string deliberately for a
new release. Each run also writes a local summary of that run to deployments/testnets.json
(gitignored, overwritten per run); the canonical deployment record lives in
DEPLOYMENTS.md — deployed addresses, chain IDs, and per-network status.
Tron cannot share these addresses (different address derivation, no CREATE2 factory) and is
deployed separately with plain deploys via TronWeb — npm install, set TRON_PRIVATE_KEY, then
npm run deploy:tron:nile (or :shasta / :mainnet). Requires TVM ≥ GreatVoyage-v4.8.0 (Kant)
for transient storage; Nile/Shasta have it.
Tempo (targets the Osaka hard fork, no native gas token) deploys a different contract
(src/tempo/Train.sol, not Train.sol) via a dedicated [profile.tempo] build
profile and script/tempo/DeployTempo.s.sol — no TrainRouter (see trust assumptions 7–9 above). Full
walkthrough, connection details, the native gasless-intake flow, and solver operational notes: see
script/tempo/README.md.
⚠️ No guarantee of security is given. An independent audit and a bug bounty are recommended before mainnet use.