Skip to content

Commit 8cdb4cb

Browse files
fix solver-first swaps; support solver-led HTLC init; extend native tests with solver-first coverage
1 parent 01ea9ca commit 8cdb4cb

3 files changed

Lines changed: 148 additions & 53 deletions

File tree

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,36 @@
1-
name: EVM ERC20 Contract Tests
1+
# name: EVM ERC20 Contract Tests
22

3-
on:
4-
push:
5-
branches: [main-add-evm]
6-
paths:
7-
- 'chains/evm/solidity/contracts/TrainERC20.sol'
8-
- 'chains/evm/solidity/test/erc20.js'
3+
# on:
4+
# push:
5+
# branches: [main-add-evm]
6+
# paths:
7+
# - 'chains/evm/solidity/contracts/TrainERC20.sol'
8+
# - 'chains/evm/solidity/test/erc20.js'
99

10-
pull_request:
11-
branches: [main-add-evm]
12-
paths:
13-
- 'chains/evm/solidity/contracts/TrainERC20.sol'
14-
- 'chains/evm/solidity/test/erc20.js'
15-
workflow_dispatch:
10+
# pull_request:
11+
# branches: [main-add-evm]
12+
# paths:
13+
# - 'chains/evm/solidity/contracts/TrainERC20.sol'
14+
# - 'chains/evm/solidity/test/erc20.js'
15+
# workflow_dispatch:
1616

17-
jobs:
18-
erc20-test:
19-
runs-on: ubuntu-latest
20-
steps:
21-
- name: Checkout repository
22-
uses: actions/checkout@v4
17+
# jobs:
18+
# erc20-test:
19+
# runs-on: ubuntu-latest
20+
# steps:
21+
# - name: Checkout repository
22+
# uses: actions/checkout@v4
2323

24-
- name: Setup Node.js
25-
uses: actions/setup-node@v4
26-
with:
27-
node-version: 20
28-
cache: npm
24+
# - name: Setup Node.js
25+
# uses: actions/setup-node@v4
26+
# with:
27+
# node-version: 20
28+
# cache: npm
2929

30-
- name: Install dependencies
31-
working-directory: chains/evm/solidity
32-
run: npm ci
30+
# - name: Install dependencies
31+
# working-directory: chains/evm/solidity
32+
# run: npm ci
3333

34-
- name: Run ERC20 tests
35-
working-directory: chains/evm/solidity
36-
run: npx hardhat test test/erc20.js --bail --parallel
34+
# - name: Run ERC20 tests
35+
# working-directory: chains/evm/solidity
36+
# run: npx hardhat test test/erc20.js --bail --parallel

chains/evm/solidity/contracts/Train.sol

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,8 @@ contract Train is ReentrancyGuard {
2626
error HashlockNotMatch();
2727
error AlreadyClaimed();
2828
error InvalidTimelock();
29-
error InvaliRewardData();
29+
error InvalidRewardTimelock();
3030
error SwapAlreadyInitialized();
31-
error SwapNotInitialized();
3231
error InvalidSwapOwner();
3332
error TransferFailed();
3433

@@ -109,7 +108,7 @@ contract Train is ReentrancyGuard {
109108
mapping(address => bytes32[]) private userSwaps;
110109

111110
/// @notice Locks funds in a new hashed time-locked contract (HTLC).
112-
/// @dev Creates an HTLC with the specified details and emits a `TokenLocked` event. The htlcId is automatically generated.
111+
/// @dev Creates an HTLC with the specified details and emits a `UserLocked` or `SolverLocked` event. The htlcId is automatically generated.
113112
/// @param swapId The identifier for the swap (can have multiple HTLCs).
114113
/// @param hashlock The hash of the secret required for redeeming the HTLC.
115114
/// @param reward The reward amount in wei granted to the caller of redeem.
@@ -120,7 +119,7 @@ contract Train is ReentrancyGuard {
120119
/// @param dstChain The destination blockchain for the swap.
121120
/// @param dstAddress The recipient address on the destination chain.
122121
/// @param dstAsset The asset on the destination chain.
123-
/// @return uint256 The unique identifier of the created HTLC.
122+
/// @return (bytes32, uint256) Returns the swapId and the unique htlcId of the created HTLC.
124123
function lock(
125124
bytes32 swapId,
126125
bytes32 hashlock,
@@ -137,25 +136,27 @@ contract Train is ReentrancyGuard {
137136
if (block.timestamp + 900 > timelock) revert InvalidTimelock();
138137
bool isSolver = reward > 0;
139138
if (isSolver) {
140-
if (rewardTimelock > timelock || rewardTimelock <= block.timestamp) revert InvaliRewardData();
139+
if (rewardTimelock > timelock || rewardTimelock <= block.timestamp) revert InvalidRewardTimelock();
141140
}
142141

143-
HTLC storage meta = contracts[swapId][0];
142+
uint256 htlcId;
143+
bool isNewSwap = (contracts[swapId][0].sender == address(0)); // true when swapId does not exist yet
144144

145-
bool isNewSwap = (meta.sender == address(0));
146-
uint256 htlcId = 0;
147-
148-
if (isNewSwap && !isSolver) {
145+
if (!isSolver) {
146+
// User can only initialize a swap once and always maps to slot 0
147+
if (!isNewSwap) revert SwapAlreadyInitialized();
149148
userSwaps[msg.sender].push(swapId);
150-
}
151-
152-
if (!isNewSwap && !isSolver) {
153-
revert SwapAlreadyInitialized();
154-
} else if (!isNewSwap && isSolver) {
155-
htlcId = 1;
156-
while (contracts[swapId][htlcId].sender != address(0)) {
157-
unchecked {
158-
htlcId++;
149+
htlcId = 0;
150+
} else {
151+
// Solvers can either initialize the swap (take slot 0) or append additional offers
152+
if (isNewSwap) {
153+
htlcId = 0; // solver is first to open the swap so they occupy slot 0
154+
} else {
155+
htlcId = 1;
156+
while (contracts[swapId][htlcId].sender != address(0)) {
157+
unchecked {
158+
htlcId++; // find next free slot for additional solver HTLCs
159+
}
159160
}
160161
}
161162
}
@@ -213,7 +214,7 @@ contract Train is ReentrancyGuard {
213214
/// @return bool Returns `true` if the refund is successful.
214215
function refund(bytes32 swapId, uint256 htlcId) external _exists(swapId, htlcId) nonReentrant returns (bool) {
215216
HTLC storage htlc = contracts[swapId][htlcId];
216-
if (htlc.claimed == 2 || htlc.claimed == 3) revert AlreadyClaimed();
217+
if (htlc.claimed != 1) revert AlreadyClaimed();
217218
if (htlc.timelock > block.timestamp) revert NotPassedTimelock();
218219

219220
htlc.claimed = 2;
@@ -240,7 +241,7 @@ contract Train is ReentrancyGuard {
240241
HTLC storage htlc = contracts[swapId][htlcId];
241242

242243
if (htlc.hashlock != sha256(abi.encodePacked(secret))) revert HashlockNotMatch();
243-
if (htlc.claimed == 3 || htlc.claimed == 2) revert AlreadyClaimed();
244+
if (htlc.claimed != 1) revert AlreadyClaimed();
244245

245246
htlc.claimed = 3;
246247
htlc.secret = secret;

chains/evm/solidity/test/native.js

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,100 @@ describe('Train', function () {
242242
expect(solverHTLC.reward).to.equal(reward);
243243
});
244244

245+
it('permits a solver-first swap and blocks later user initialization', async function () {
246+
const { train, initiator, solverA, solverB, receiver } = await loadFixture(deployTrainFixture);
247+
const swapId = ethers.id('solver-first');
248+
const solverAmount = ethers.parseEther('0.8');
249+
const solverReward = ethers.parseEther('0.15');
250+
const timelock = await futureTimestamp(4200);
251+
const rewardTimelock = timelock - 30;
252+
const hashlock = hashSecret(901n);
253+
254+
await expect(
255+
train
256+
.connect(solverA)
257+
.lock(
258+
swapId,
259+
hashlock,
260+
solverReward,
261+
rewardTimelock,
262+
timelock,
263+
receiver.address,
264+
DEFAULT_META.srcAsset,
265+
DEFAULT_META.dstChain,
266+
DEFAULT_META.dstAddress,
267+
DEFAULT_META.dstAsset,
268+
{ value: solverAmount + solverReward }
269+
)
270+
)
271+
.to.emit(train, 'SolverLocked')
272+
.withArgs(
273+
swapId,
274+
0,
275+
hashlock,
276+
DEFAULT_META.dstChain,
277+
DEFAULT_META.dstAddress,
278+
DEFAULT_META.dstAsset,
279+
solverA.address,
280+
receiver.address,
281+
DEFAULT_META.srcAsset,
282+
solverAmount,
283+
solverReward,
284+
rewardTimelock,
285+
timelock
286+
);
287+
288+
const solverDetails = await train.getHTLCDetails(swapId, 0);
289+
expect(solverDetails.sender).to.equal(solverA.address);
290+
expect(solverDetails.amount).to.equal(solverAmount);
291+
expect(solverDetails.reward).to.equal(solverReward);
292+
293+
await expect(lockUserHTLC(train, initiator, receiver, { swapId })).to.be.revertedWithCustomError(
294+
train,
295+
'SwapAlreadyInitialized'
296+
);
297+
298+
const secondHashlock = hashSecret(902n);
299+
const secondTimelock = await futureTimestamp(5200);
300+
const secondRewardTimelock = secondTimelock - 60;
301+
await expect(
302+
train
303+
.connect(solverB)
304+
.lock(
305+
swapId,
306+
secondHashlock,
307+
solverReward,
308+
secondRewardTimelock,
309+
secondTimelock,
310+
receiver.address,
311+
DEFAULT_META.srcAsset,
312+
DEFAULT_META.dstChain,
313+
DEFAULT_META.dstAddress,
314+
DEFAULT_META.dstAsset,
315+
{ value: solverAmount + solverReward }
316+
)
317+
)
318+
.to.emit(train, 'SolverLocked')
319+
.withArgs(
320+
swapId,
321+
1,
322+
secondHashlock,
323+
DEFAULT_META.dstChain,
324+
DEFAULT_META.dstAddress,
325+
DEFAULT_META.dstAsset,
326+
solverB.address,
327+
receiver.address,
328+
DEFAULT_META.srcAsset,
329+
solverAmount,
330+
solverReward,
331+
secondRewardTimelock,
332+
secondTimelock
333+
);
334+
335+
const userSwaps = await train.getUserSwaps(initiator.address);
336+
expect(userSwaps.length).to.equal(0);
337+
});
338+
245339
it('reverts when the timelock is sooner than 15 minutes', async function () {
246340
const { train, initiator, receiver } = await loadFixture(deployTrainFixture);
247341
const soon = (await time.latest()) + 899;
@@ -313,7 +407,7 @@ describe('Train', function () {
313407
DEFAULT_META.dstAsset,
314408
{ value: reward + ethers.parseEther('1') }
315409
)
316-
).to.be.revertedWithCustomError(train, 'InvaliRewardData');
410+
).to.be.revertedWithCustomError(train, 'InvalidRewardTimelock');
317411

318412
const now = await time.latest();
319413
await expect(
@@ -332,7 +426,7 @@ describe('Train', function () {
332426
DEFAULT_META.dstAsset,
333427
{ value: reward + ethers.parseEther('1') }
334428
)
335-
).to.be.revertedWithCustomError(train, 'InvaliRewardData');
429+
).to.be.revertedWithCustomError(train, 'InvalidRewardTimelock');
336430
});
337431
});
338432

0 commit comments

Comments
 (0)