Skip to content

Commit 8bf4dc6

Browse files
authored
Merge pull request #4 from thirdweb-dev/yash/eip712
Change to EIP712 signature verification
2 parents 38a45c7 + 7ba821e commit 8bf4dc6

File tree

11 files changed

+1104
-227
lines changed

11 files changed

+1104
-227
lines changed

.gas-snapshot

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
BenchmarkPaymentsGatewayPullTest:test_startTransfer_erc20() (gas: 184188)
2+
BenchmarkPaymentsGatewayPullTest:test_startTransfer_nativeToken() (gas: 181962)
3+
BenchmarkPaymentsGatewaySplitTest:test_startTransfer_erc20() (gas: 131301)
4+
BenchmarkPaymentsGatewaySplitTest:test_startTransfer_nativeToken() (gas: 141479)
5+
BenchmarkPaymentsGatewayTest:test_startTransfer_erc20() (gas: 158244)
6+
BenchmarkPaymentsGatewayTest:test_startTransfer_nativeToken() (gas: 183194)
7+
PaymentsGatewayTest:test_endTransfer_erc20() (gas: 72862)
8+
PaymentsGatewayTest:test_endTransfer_events() (gas: 65840)
9+
PaymentsGatewayTest:test_endTransfer_nativeToken() (gas: 69600)
10+
PaymentsGatewayTest:test_revert_startTransfer_invalidSignature() (gas: 92090)
11+
PaymentsGatewayTest:test_startTransfer_erc20() (gas: 228002)
12+
PaymentsGatewayTest:test_startTransfer_events() (gas: 215777)
13+
PaymentsGatewayTest:test_startTransfer_nativeToken() (gas: 232747)

foundry.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ solc-version = "0.8.23"
33
#auto_detect_solc = false
44
cache = true
55
evm_version = 'london'
6+
gas_reports = [
7+
"BenchmarkPaymentsGateway",
8+
"BenchmarkPaymentsGatewayPull",
9+
"BenchmarkPaymentsGatewaySplit",
10+
]
611
src = 'src'
712
test = 'test'
813
out = 'artifacts_forge'

gasreport.txt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
No files changed, compilation skipped
2+
3+
Running 2 tests for test/benchmarks/BenchmarkPaymentsGatewaySplit.t.sol:BenchmarkPaymentsGatewaySplitTest
4+
[PASS] test_startTransfer_erc20() (gas: 131301)
5+
[PASS] test_startTransfer_nativeToken() (gas: 141479)
6+
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.69ms
7+
8+
Running 2 tests for test/benchmarks/BenchmarkPaymentsGateway.t.sol:BenchmarkPaymentsGatewayTest
9+
[PASS] test_startTransfer_erc20() (gas: 158244)
10+
[PASS] test_startTransfer_nativeToken() (gas: 183194)
11+
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.94ms
12+
13+
Running 2 tests for test/benchmarks/BenchmarkPaymentsGatewayPull.t.sol:BenchmarkPaymentsGatewayPullTest
14+
[PASS] test_startTransfer_erc20() (gas: 184188)
15+
[PASS] test_startTransfer_nativeToken() (gas: 181962)
16+
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 1.81ms
17+
18+
19+
Ran 3 test suites: 6 tests passed, 0 failed, 0 skipped (6 total tests)
20+
test_startTransfer_erc20() (gas: 0 (0.000%))
21+
test_startTransfer_nativeToken() (gas: 0 (0.000%))
22+
test_startTransfer_erc20() (gas: 0 (0.000%))
23+
test_startTransfer_nativeToken() (gas: 0 (0.000%))
24+
test_startTransfer_erc20() (gas: 0 (0.000%))
25+
test_startTransfer_nativeToken() (gas: 0 (0.000%))
26+
Overall gas change: 0 (0.000%)

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"scripts": {
1515
"prettier": "prettier --config .prettierrc --write --plugin=prettier-plugin-solidity '{contracts,src,test}/**/*.sol'",
1616
"prettier:list-different": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '**/*.sol'",
17-
"prettier:contracts": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '{contracts,src, test}/**/*.sol'"
17+
"prettier:contracts": "prettier --config .prettierrc --plugin=prettier-plugin-solidity --list-different '{contracts,src, test}/**/*.sol'",
18+
"gas": "forge snapshot --mc Benchmark --gas-report --diff .gas-snapshot > gasreport.txt"
1819
}
1920
}

src/PaymentsGateway.sol

Lines changed: 81 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22
pragma solidity ^0.8.22;
33

44
import "@openzeppelin/contracts/access/Ownable.sol";
5-
import "@openzeppelin/contracts/utils/Context.sol";
65
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
76
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
7+
import { EIP712 } from "./utils/EIP712.sol";
8+
89
import { SafeTransferLib } from "./lib/SafeTransferLib.sol";
10+
import { ECDSA } from "./lib/ECDSA.sol";
911

1012
/**
1113
Requirements
@@ -18,13 +20,14 @@ import { SafeTransferLib } from "./lib/SafeTransferLib.sol";
1820
- add operator role automating withdrawals
1921
*/
2022

21-
contract PaymentsGateway is Ownable, ReentrancyGuard {
22-
error PaymentsGatewayInvalidOperator(address operator);
23-
error PaymentsGatewayNotOwnerOrOperator(address caller);
23+
contract PaymentsGateway is EIP712, Ownable, ReentrancyGuard {
24+
using ECDSA for bytes32;
25+
2426
error PaymentsGatewayMismatchedValue(uint256 expected, uint256 actual);
2527
error PaymentsGatewayInvalidAmount(uint256 amount);
2628
error PaymentsGatewayVerificationFailed();
2729
error PaymentsGatewayFailedToForward();
30+
error PaymentsGatewayRequestExpired(uint256 expirationTimestamp);
2831

2932
event TransferStart(
3033
bytes32 indexed clientId,
@@ -64,52 +67,45 @@ contract PaymentsGateway is Ownable, ReentrancyGuard {
6467
address payable payoutAddress;
6568
uint256 feeBPS;
6669
}
70+
struct PayRequest {
71+
bytes32 clientId;
72+
bytes32 transactionId;
73+
address tokenAddress;
74+
uint256 tokenAmount;
75+
uint256 expirationTimestamp;
76+
PayoutInfo[] payouts;
77+
address payable forwardAddress;
78+
bytes data;
79+
}
6780

81+
bytes32 private constant PAYOUTINFO_TYPEHASH =
82+
keccak256("PayoutInfo(bytes32 clientId,address payoutAddress,uint256 feeBPS)");
83+
bytes32 private constant REQUEST_TYPEHASH =
84+
keccak256(
85+
"PayRequest(bytes32 clientId,bytes32 transactionId,address tokenAddress,uint256 tokenAmount,uint256 expirationTimestamp,PayoutInfo[] payouts,address forwardAddress,bytes data)PayoutInfo(bytes32 clientId,address payoutAddress,uint256 feeBPS)"
86+
);
6887
address private constant THIRDWEB_CLIENT_ID = 0x0000000000000000000000000000000000000000;
6988
address private constant NATIVE_TOKEN_ADDRESS = 0x0000000000000000000000000000000000000000;
70-
address private _operator;
7189

72-
constructor(address contractOwner, address initialOperator) Ownable(contractOwner) {
73-
if (initialOperator == address(0)) {
74-
revert PaymentsGatewayInvalidOperator(initialOperator);
75-
}
76-
_operator = initialOperator;
77-
emit OperatorChanged(address(0), initialOperator);
78-
}
90+
/// @dev Mapping from pay request UID => whether the pay request is processed.
91+
mapping(bytes32 => bool) private processed;
7992

80-
modifier onlyOwnerOrOperator() {
81-
if (msg.sender != owner() && msg.sender != _operator) {
82-
revert PaymentsGatewayNotOwnerOrOperator(msg.sender);
83-
}
84-
_;
85-
}
86-
87-
function setOperator(address newOperator) public onlyOwnerOrOperator {
88-
if (newOperator == address(0)) {
89-
revert PaymentsGatewayInvalidOperator(newOperator);
90-
}
91-
emit OperatorChanged(_operator, newOperator);
92-
_operator = newOperator;
93-
}
94-
95-
function getOperator() public view returns (address) {
96-
return _operator;
97-
}
93+
constructor(address contractOwner) Ownable(contractOwner) {}
9894

9995
/* some bridges may refund need a way to get funds back to user */
10096
function withdrawTo(
10197
address tokenAddress,
10298
uint256 tokenAmount,
10399
address payable receiver
104-
) public onlyOwnerOrOperator nonReentrant {
100+
) public onlyOwner nonReentrant {
105101
if (_isTokenERC20(tokenAddress)) {
106102
SafeTransferLib.safeTransferFrom(tokenAddress, address(this), receiver, tokenAmount);
107103
} else {
108104
SafeTransferLib.safeTransferETH(receiver, tokenAmount);
109105
}
110106
}
111107

112-
function withdraw(address tokenAddress, uint256 tokenAmount) external onlyOwnerOrOperator nonReentrant {
108+
function withdraw(address tokenAddress, uint256 tokenAmount) external onlyOwner nonReentrant {
113109
withdrawTo(tokenAddress, tokenAmount, payable(msg.sender));
114110
}
115111

@@ -158,61 +154,42 @@ contract PaymentsGateway is Ownable, ReentrancyGuard {
158154
return totalFeeAmount;
159155
}
160156

157+
function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) {
158+
name = "PaymentsGateway";
159+
version = "1";
160+
}
161+
161162
function _hashPayoutInfo(PayoutInfo[] calldata payouts) private pure returns (bytes32) {
162-
bytes32 payoutHash = keccak256(abi.encodePacked("PayoutInfo"));
163-
for (uint256 i = 0; i < payouts.length; ++i) {
164-
payoutHash = keccak256(
165-
abi.encodePacked(payoutHash, payouts[i].clientId, payouts[i].payoutAddress, payouts[i].feeBPS)
163+
bytes32[] memory payoutsHashes = new bytes32[](payouts.length);
164+
for (uint i = 0; i < payouts.length; i++) {
165+
payoutsHashes[i] = keccak256(
166+
abi.encode(PAYOUTINFO_TYPEHASH, payouts[i].clientId, payouts[i].payoutAddress, payouts[i].feeBPS)
166167
);
167168
}
168-
return payoutHash;
169+
return keccak256(abi.encodePacked(payoutsHashes));
169170
}
170171

171-
function _verifyTransferStart(
172-
bytes32 clientId,
173-
bytes32 transactionId,
174-
address tokenAddress,
175-
uint256 tokenAmount,
176-
PayoutInfo[] calldata payouts,
177-
address payable forwardAddress,
178-
bytes calldata data,
179-
bytes calldata signature
180-
) private returns (bool) {
181-
bytes32 payoutsHash = _hashPayoutInfo(payouts);
182-
bytes32 hash = keccak256(
183-
abi.encodePacked(clientId, transactionId, tokenAddress, tokenAmount, payoutsHash, forwardAddress, data)
172+
function _verifyTransferStart(PayRequest calldata req, bytes calldata signature) private view returns (bool) {
173+
bytes32 payoutsHash = _hashPayoutInfo(req.payouts);
174+
bytes32 structHash = keccak256(
175+
abi.encode(
176+
REQUEST_TYPEHASH,
177+
req.clientId,
178+
req.transactionId,
179+
req.tokenAddress,
180+
req.tokenAmount,
181+
req.expirationTimestamp,
182+
payoutsHash,
183+
req.forwardAddress,
184+
keccak256(req.data)
185+
)
184186
);
185187

186-
bytes32 ethSignedMsgHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", hash));
187-
188-
(address recovered, bool valid) = _recoverSigner(ethSignedMsgHash, signature);
189-
190-
return valid && recovered == _operator;
191-
}
192-
193-
function _recoverSigner(bytes32 ethSignedMsgHash, bytes memory signature) public pure returns (address, bool) {
194-
bytes32 r;
195-
bytes32 s;
196-
uint8 v;
188+
bytes32 digest = _hashTypedData(structHash);
189+
address recovered = digest.recover(signature);
190+
bool valid = recovered == owner() && !processed[req.transactionId];
197191

198-
if (signature.length != 65) {
199-
return (address(0), false);
200-
}
201-
202-
assembly {
203-
r := mload(add(signature, 0x20))
204-
s := mload(add(signature, 0x40))
205-
v := byte(0, mload(add(signature, 0x60)))
206-
}
207-
208-
if (v < 27) {
209-
v += 27;
210-
}
211-
212-
address recovered = ecrecover(ethSignedMsgHash, v, r, s);
213-
bool valid = (recovered != address(0));
214-
215-
return (recovered, valid);
192+
return valid;
216193
}
217194

218195
/**
@@ -225,64 +202,53 @@ contract PaymentsGateway is Ownable, ReentrancyGuard {
225202
3. distribute the fees to all the payees (thirdweb, developer, swap provider??)
226203
4. forward the user funds to the swap provider (forwardAddress)
227204
*/
228-
function startTransfer(
229-
bytes32 clientId,
230-
bytes32 transactionId,
231-
address tokenAddress,
232-
uint256 tokenAmount,
233-
PayoutInfo[] calldata payouts,
234-
address payable forwardAddress,
235-
bytes calldata data,
236-
bytes calldata signature
237-
) external payable nonReentrant {
205+
206+
function startTransfer(PayRequest calldata req, bytes calldata signature) external payable nonReentrant {
238207
// verify amount
239-
if (tokenAmount == 0) {
240-
revert PaymentsGatewayInvalidAmount(tokenAmount);
208+
if (req.tokenAmount == 0) {
209+
revert PaymentsGatewayInvalidAmount(req.tokenAmount);
210+
}
211+
212+
// verify expiration timestamp
213+
if (req.expirationTimestamp < block.timestamp) {
214+
revert PaymentsGatewayRequestExpired(req.expirationTimestamp);
241215
}
242216

243217
// verify data
244-
if (
245-
!_verifyTransferStart(
246-
clientId,
247-
transactionId,
248-
tokenAddress,
249-
tokenAmount,
250-
payouts,
251-
forwardAddress,
252-
data,
253-
signature
254-
)
255-
) {
218+
if (!_verifyTransferStart(req, signature)) {
256219
revert PaymentsGatewayVerificationFailed();
257220
}
258221

259-
if (_isTokenNative(tokenAddress)) {
260-
if (msg.value < tokenAmount) {
261-
revert PaymentsGatewayMismatchedValue(tokenAmount, msg.value);
222+
if (_isTokenNative(req.tokenAddress)) {
223+
if (msg.value < req.tokenAmount) {
224+
revert PaymentsGatewayMismatchedValue(req.tokenAmount, msg.value);
262225
}
263226
}
264227

228+
// mark the pay request as processed
229+
processed[req.transactionId] = true;
230+
265231
// distribute fees
266-
uint256 totalFeeAmount = _distributeFees(tokenAddress, tokenAmount, payouts);
232+
uint256 totalFeeAmount = _distributeFees(req.tokenAddress, req.tokenAmount, req.payouts);
267233

268234
// determine native value to send
269235
uint256 sendValue = msg.value; // includes bridge fee etc. (if any)
270-
if (_isTokenNative(tokenAddress)) {
236+
if (_isTokenNative(req.tokenAddress)) {
271237
sendValue = msg.value - totalFeeAmount;
272238

273-
if (sendValue < tokenAmount) {
274-
revert PaymentsGatewayMismatchedValue(sendValue, tokenAmount);
239+
if (sendValue < req.tokenAmount) {
240+
revert PaymentsGatewayMismatchedValue(sendValue, req.tokenAmount);
275241
}
276242
}
277243

278-
if (_isTokenERC20(tokenAddress)) {
244+
if (_isTokenERC20(req.tokenAddress)) {
279245
// pull user funds
280-
SafeTransferLib.safeTransferFrom(tokenAddress, msg.sender, address(this), tokenAmount);
281-
SafeTransferLib.safeApprove(tokenAddress, forwardAddress, tokenAmount);
246+
SafeTransferLib.safeTransferFrom(req.tokenAddress, msg.sender, address(this), req.tokenAmount);
247+
SafeTransferLib.safeApprove(req.tokenAddress, req.forwardAddress, req.tokenAmount);
282248
}
283249

284250
{
285-
(bool success, bytes memory response) = forwardAddress.call{ value: sendValue }(data);
251+
(bool success, bytes memory response) = req.forwardAddress.call{ value: sendValue }(req.data);
286252
if (!success) {
287253
// If there is return data, the delegate call reverted with a reason or a custom error, which we bubble up.
288254
if (response.length > 0) {
@@ -296,7 +262,7 @@ contract PaymentsGateway is Ownable, ReentrancyGuard {
296262
}
297263
}
298264

299-
emit TransferStart(clientId, msg.sender, transactionId, tokenAddress, tokenAmount);
265+
emit TransferStart(req.clientId, msg.sender, req.transactionId, req.tokenAddress, req.tokenAmount);
300266
}
301267

302268
/**

0 commit comments

Comments
 (0)