Skip to content

Commit 5b747ac

Browse files
feat(evm): add user historical swap tracking
1 parent 8dc7cef commit 5b747ac

4 files changed

Lines changed: 2124 additions & 1668 deletions

File tree

chains/evm/solidity/README.md

Lines changed: 89 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ Trustless cross-chain bridge using Hashed Time-Locked Contracts (HTLC).
66

77
Train Protocol enables permissionless cross-chain token swaps without trusted intermediaries. Users lock funds on the source chain, solvers fulfill the swap on the destination chain, and atomic reveals ensure either both sides complete or both refund.
88

9-
109
## Contract Architecture
1110

1211
### Single Unified Contract
@@ -61,6 +60,7 @@ struct UserLockParams {
6160
```
6261

6362
**Requirements:**
63+
6464
- `amount > 0`
6565
- `timelockDelta > 0`
6666
- `block.timestamp < quoteExpiry`
@@ -72,6 +72,7 @@ struct UserLockParams {
7272
Redeems a user lock with the secret preimage. Anyone can call this.
7373

7474
**Requirements:**
75+
7576
- Lock must exist and be pending
7677
- `sha256(secret) == hashlock`
7778

@@ -80,6 +81,7 @@ Redeems a user lock with the secret preimage. Anyone can call this.
8081
Refunds a user lock back to the sender.
8182

8283
**Access Control:**
84+
8385
- `recipient` can refund **anytime**
8486
- Anyone else can refund **after timelock expires**
8587

@@ -166,6 +168,7 @@ All state-changing functions use OpenZeppelin's `ReentrancyGuard` with the `nonR
166168
### ETH Transfer Gas Stipend
167169

168170
ETH transfers use a 10,000 gas stipend to prevent:
171+
169172
- Griefing attacks via gas-expensive `receive()` functions
170173
- Reentrancy through ETH transfers
171174

@@ -179,6 +182,7 @@ if (!success) revert TransferFailed();
179182
### Safe Token Transfers
180183

181184
ERC20 transfers use OpenZeppelin's `SafeERC20` to handle:
185+
182186
- Tokens that don't return booleans (USDT)
183187
- Tokens that revert on failure
184188
- Tokens with non-standard implementations
@@ -213,20 +217,71 @@ Emitted when a lock is refunded.
213217

214218
## Error Codes
215219

216-
| Error | Description |
217-
|-------|-------------|
218-
| `ZeroAmount` | Lock amount is zero |
219-
| `LockNotFound` | No lock exists for hashlock/index |
220-
| `HashlockMismatch` | Provided secret doesn't hash to hashlock |
221-
| `LockNotPending` | Lock already redeemed or refunded |
222-
| `InvalidTimelock` | Timelock delta is zero |
223-
| `InvalidRewardTimelock` | rewardTimelockDelta >= timelockDelta |
224-
| `SwapAlreadyExists` | User lock already exists for hashlock |
225-
| `TransferFailed` | ETH transfer failed |
226-
| `MsgValueMismatch` | msg.value doesn't match expected ETH |
227-
| `RefundNotAllowed` | Refund attempted too early |
228-
| `InvalidToken` | Token address has no code |
229-
| `QuoteExpired` | Quote expiry timestamp passed |
220+
| Error | Description |
221+
| ----------------------- | ---------------------------------------- |
222+
| `ZeroAmount` | Lock amount is zero |
223+
| `LockNotFound` | No lock exists for hashlock/index |
224+
| `HashlockMismatch` | Provided secret doesn't hash to hashlock |
225+
| `LockNotPending` | Lock already redeemed or refunded |
226+
| `InvalidTimelock` | Timelock delta is zero |
227+
| `InvalidRewardTimelock` | rewardTimelockDelta >= timelockDelta |
228+
| `SwapAlreadyExists` | User lock already exists for hashlock |
229+
| `TransferFailed` | ETH transfer failed |
230+
| `MsgValueMismatch` | msg.value doesn't match expected ETH |
231+
| `RefundNotAllowed` | Refund attempted too early |
232+
| `InvalidToken` | Token address has no code |
233+
| `QuoteExpired` | Quote expiry timestamp passed |
234+
235+
## Historical Swap Tracking
236+
237+
The contract provides built-in historical swap tracking for user locks, allowing applications to query all swaps created by a specific address.
238+
239+
### Storage Tracking
240+
241+
```solidity
242+
mapping(address => bytes32[]) private userLockHashes;
243+
```
244+
245+
Each time a user creates a lock via `userLock()`, the hashlock is automatically appended to their history array. This tracking:
246+
247+
- **Persists forever** - Hashlocks remain in history even after redemption or refund
248+
- **Tracks initiators only** - Only the `sender` address is tracked, not recipients
249+
- **On-chain query support** - Enables frontend applications to display user swap history
250+
251+
### Query Functions
252+
253+
#### `getUserLockHashes(address user)`
254+
255+
Returns an array of all hashlocks for swaps created by the user.
256+
257+
```solidity
258+
bytes32[] memory hashlocks = train.getUserLockHashes(userAddress);
259+
// Returns: [hashlock1, hashlock2, hashlock3, ...]
260+
```
261+
262+
**Use case:** Efficiently fetch all swap identifiers, then query individual lock details as needed.
263+
264+
#### `getUserLocks(address user)`
265+
266+
Returns an array of complete `UserLock` structs for all swaps created by the user.
267+
268+
```solidity
269+
Train.UserLock[] memory locks = train.getUserLocks(userAddress);
270+
for (uint i = 0; i < locks.length; i++) {
271+
// Access: locks[i].amount, locks[i].status, locks[i].secret, etc.
272+
}
273+
```
274+
275+
**Use case:** Fetch full swap history with all details (amount, token, status, recipient, timelock).
276+
277+
### Status Tracking
278+
279+
The returned locks include real-time status:
280+
281+
- `LockStatus.Pending` - Active swap awaiting redemption
282+
- `LockStatus.Redeemed` - Completed swap (includes revealed `secret`)
283+
- `LockStatus.Refunded` - Cancelled/expired swap
284+
230285

231286
## Usage Examples
232287

@@ -384,29 +439,31 @@ The contract targets **Cancun** EVM version. Ensure the target network supports
384439

385440
### Deployment
386441

387-
| Metric | Value |
388-
|--------|-------|
389-
| Deployment Cost | ~1,365,038 gas |
390-
| Contract Size | 6,023 bytes |
442+
| Metric | Value |
443+
| --------------- | ------------- |
444+
| Deployment Cost | 1,545,494 gas |
445+
| Contract Size | 6,858 bytes |
391446

392447
### Function Costs
393448

394449
Gas costs vary based on token type (ETH vs ERC20) and storage operations (cold vs warm slots).
395450

396-
| Function | Min | Avg | Median | Max | Description |
397-
|----------|-----|-----|--------|-----|-------------|
398-
| **User Operations** |
399-
| `userLock` | 32,522 | 117,029 | 111,424 | 166,496 | Higher for ERC20 (transfer) |
400-
| `redeemUser` | 29,262 | 94,061 | 94,805 | 95,117 | Reveals secret, transfers out |
401-
| `refundUser` | 29,165 | 39,077 | 46,580 | 47,742 | Returns funds to sender |
451+
| Function | Min | Avg | Median | Max | Description |
452+
| --------------------- | ------ | ------- | ------- | ------- | ------------------------------------ |
453+
| **User Operations** |
454+
| `userLock` | 32,614 | 149,029 | 155,986 | 211,142 | Higher for ERC20 + hashlock tracking |
455+
| `redeemUser` | 29,262 | 94,464 | 94,817 | 95,129 | Reveals secret, transfers out |
456+
| `refundUser` | 29,187 | 41,593 | 46,642 | 47,764 | Returns funds to sender |
402457
| **Solver Operations** |
403-
| `solverLock` | 31,677 | 187,185 | 179,182 | 289,428 | Higher for mixed token types |
404-
| `redeemSolver` | 29,412 | 112,897 | 107,029 | 136,942 | 2 transfers (amount + reward) |
405-
| `refundSolver` | 29,469 | 51,726 | 51,750 | 63,438 | Returns amount + reward |
406-
| **View Functions** |
407-
| `getUserLock` | 11,579 | 11,579 | 11,579 | 11,579 | Read 5 storage slots |
408-
| `getSolverLock` | 18,380 | 18,380 | 18,380 | 18,380 | Read 8 storage slots |
409-
| `getSolverLockCount` | 2,457 | 2,457 | 2,457 | 2,457 | Read 1 storage slot |
458+
| `solverLock` | 31,747 | 187,420 | 179,288 | 289,546 | Higher for mixed token types |
459+
| `redeemSolver` | 29,412 | 112,898 | 107,041 | 136,954 | 2 transfers (amount + reward) |
460+
| `refundSolver` | 29,513 | 51,771 | 51,794 | 63,482 | Returns amount + reward |
461+
| **View Functions** |
462+
| `getUserLock` | 11,723 | 11,723 | 11,723 | 11,723 | Read 5 storage slots |
463+
| `getSolverLock` | 18,426 | 18,426 | 18,426 | 18,426 | Read 8 storage slots |
464+
| `getSolverLockCount` | 2,479 | 2,479 | 2,479 | 2,479 | Read 1 storage slot |
465+
| `getUserLockHashes` | 2,869 | 9,553 | 5,140 | 48,294 | Scales with user's swap count |
466+
| `getUserLocks` | 3,101 | 32,140 | 17,234 | 144,525 | Fetches full history (expensive) |
410467

411468
### Gas Optimization Notes
412469

chains/evm/solidity/src/Train.sol

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ contract Train is ReentrancyGuard {
201201
/// @dev hashlock => count of solver locks
202202
mapping(bytes32 => uint256) private solverLockCount;
203203

204+
/// @dev Historical hashlocks per user address
205+
mapping(address => bytes32[]) private userLockHashes;
206+
204207
/// @notice Create a user lock to initiate a cross-chain swap
205208
/// @param params Lock parameters including hashlock, amount, addresses, and timelocks
206209
/// @param dst Destination chain details (logged only)
@@ -226,6 +229,9 @@ contract Train is ReentrancyGuard {
226229
lock.status = LockStatus.Pending;
227230
lock.token = params.token;
228231

232+
// Track hashlock for this user
233+
userLockHashes[params.sender].push(params.hashlock);
234+
229235
_transferIn(params.token, params.amount);
230236
_emitUserLocked(params, dst, timelock, data);
231237
}
@@ -331,7 +337,14 @@ contract Train is ReentrancyGuard {
331337
lock.secret = secret;
332338

333339
address rewardTo = lock.rewardTimelock > block.timestamp ? lock.rewardRecipient : msg.sender;
334-
_transferOutMixed(lock.token, lock.amount, payable(lock.recipient), lock.rewardToken, lock.reward, payable(rewardTo));
340+
_transferOutMixed(
341+
lock.token,
342+
lock.amount,
343+
payable(lock.recipient),
344+
lock.rewardToken,
345+
lock.reward,
346+
payable(rewardTo)
347+
);
335348
emit SolverRedeemed(hashlock, index, msg.sender, secret);
336349
}
337350

@@ -357,6 +370,25 @@ contract Train is ReentrancyGuard {
357370
return solverLockCount[hashlock];
358371
}
359372

373+
/// @notice Get all hashlocks for user locks created by an address
374+
/// @param user The address to query
375+
/// @return Array of hashlocks
376+
function getUserLockHashes(address user) external view returns (bytes32[] memory) {
377+
return userLockHashes[user];
378+
}
379+
380+
/// @notice Get all user lock details created by an address
381+
/// @param user The address to query
382+
/// @return Array of UserLock structs
383+
function getUserLocks(address user) external view returns (UserLock[] memory) {
384+
bytes32[] memory hashlocks = userLockHashes[user];
385+
UserLock[] memory locks = new UserLock[](hashlocks.length);
386+
for (uint256 i = 0; i < hashlocks.length; i++) {
387+
locks[i] = userLocks[hashlocks[i]];
388+
}
389+
return locks;
390+
}
391+
360392
/// @dev Transfer ETH or ERC20 into the contract
361393
/// @param token Token address (NATIVE_ETH for ETH)
362394
/// @param amount Amount to transfer
@@ -404,7 +436,7 @@ contract Train is ReentrancyGuard {
404436
/// @param amount Amount to transfer
405437
function _transferOut(address token, address payable to, uint256 amount) internal {
406438
if (token == NATIVE_ETH) {
407-
(bool success,) = to.call{ value: amount, gas: GAS_STIPEND }('');
439+
(bool success, ) = to.call{ value: amount, gas: GAS_STIPEND }('');
408440
if (!success) revert TransferFailed();
409441
} else {
410442
IERC20(token).safeTransfer(to, amount);

0 commit comments

Comments
 (0)