Skip to content

Commit 7641fbf

Browse files
committed
feat(SpokePool): Introduce opt-in deterministic relay data hashes (#583)
1 parent 9e3df5a commit 7641fbf

File tree

4 files changed

+234
-52
lines changed

4 files changed

+234
-52
lines changed

contracts/SpokePool.sol

Lines changed: 166 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ abstract contract SpokePool is
127127

128128
bytes32 public constant UPDATE_V3_DEPOSIT_DETAILS_HASH =
129129
keccak256(
130-
"UpdateDepositDetails(uint32 depositId,uint256 originChainId,uint256 updatedOutputAmount,address updatedRecipient,bytes updatedMessage)"
130+
"UpdateDepositDetails(uint256 depositId,uint256 originChainId,uint256 updatedOutputAmount,address updatedRecipient,bytes updatedMessage)"
131131
);
132132

133133
// Default chain Id used to signify that no repayment is requested, for example when executing a slow fill.
@@ -551,59 +551,92 @@ abstract contract SpokePool is
551551
uint32 exclusivityPeriod,
552552
bytes calldata message
553553
) public payable override nonReentrant unpausedDeposits {
554-
// Check that deposit route is enabled for the input token. There are no checks required for the output token
555-
// which is pulled from the relayer at fill time and passed through this contract atomically to the recipient.
556-
if (!enabledDepositRoutes[inputToken][destinationChainId]) revert DisabledRoute();
557-
558-
// Require that quoteTimestamp has a maximum age so that depositors pay an LP fee based on recent HubPool usage.
559-
// It is assumed that cross-chain timestamps are normally loosely in-sync, but clock drift can occur. If the
560-
// SpokePool time stalls or lags significantly, it is still possible to make deposits by setting quoteTimestamp
561-
// within the configured buffer. The owner should pause deposits/fills if this is undesirable.
562-
// This will underflow if quoteTimestamp is more than depositQuoteTimeBuffer;
563-
// this is safe but will throw an unintuitive error.
564-
565-
// slither-disable-next-line timestamp
566-
uint256 currentTime = getCurrentTime();
567-
if (currentTime - quoteTimestamp > depositQuoteTimeBuffer) revert InvalidQuoteTimestamp();
568-
569-
// fillDeadline is relative to the destination chain.
570-
// Don’t allow fillDeadline to be more than several bundles into the future.
571-
// This limits the maximum required lookback for dataworker and relayer instances.
572-
// Also, don't allow fillDeadline to be in the past. This poses a potential UX issue if the destination
573-
// chain time keeping and this chain's time keeping are out of sync but is not really a practical hurdle
574-
// unless they are significantly out of sync or the depositor is setting very short fill deadlines. This latter
575-
// situation won't be a problem for honest users.
576-
if (fillDeadline < currentTime || fillDeadline > currentTime + fillDeadlineBuffer) revert InvalidFillDeadline();
577-
578-
// If the address of the origin token is a wrappedNativeToken contract and there is a msg.value with the
579-
// transaction then the user is sending the native token. In this case, the native token should be
580-
// wrapped.
581-
if (inputToken == address(wrappedNativeToken) && msg.value > 0) {
582-
if (msg.value != inputAmount) revert MsgValueDoesNotMatchInputAmount();
583-
wrappedNativeToken.deposit{ value: msg.value }();
584-
// Else, it is a normal ERC20. In this case pull the token from the caller as per normal.
585-
// Note: this includes the case where the L2 caller has WETH (already wrapped ETH) and wants to bridge them.
586-
// In this case the msg.value will be set to 0, indicating a "normal" ERC20 bridging action.
587-
} else {
588-
// msg.value should be 0 if input token isn't the wrapped native token.
589-
if (msg.value != 0) revert MsgValueDoesNotMatchInputAmount();
590-
IERC20Upgradeable(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount);
591-
}
592-
593-
emit V3FundsDeposited(
554+
_depositV3(
555+
depositor,
556+
recipient,
594557
inputToken,
595558
outputToken,
596559
inputAmount,
597560
outputAmount,
598561
destinationChainId,
562+
exclusiveRelayer,
599563
// Increment count of deposits so that deposit ID for this spoke pool is unique.
564+
// @dev Implicitly casts from uint32 to uint256 by padding the left-most bytes with zeros. Guarantees
565+
// that the 24 most significant bytes are 0.
600566
numberOfDeposits++,
601567
quoteTimestamp,
602568
fillDeadline,
603-
uint32(currentTime) + exclusivityPeriod,
569+
uint32(getCurrentTime()) + exclusivityPeriod,
570+
message
571+
);
572+
}
573+
574+
/**
575+
* @notice See depositV3 for details. This function is identical to depositV3 except that it does not use the
576+
* global deposit ID counter as a deposit nonce, instead allowing the caller to pass in a deposit nonce. This
577+
* function is designed to be used by anyone who wants to pre-compute their resultant relay data hash, which
578+
* could be useful for filling a deposit faster and avoiding any risk of a relay hash unexpectedly changing
579+
* due to another deposit front-running this one and incrementing the global deposit ID counter.
580+
* @dev This is labeled "unsafe" because there is no guarantee that the depositId emitted in the resultant
581+
* V3FundsDeposited event is unique which means that the
582+
* corresponding fill might collide with an existing relay hash on the destination chain SpokePool,
583+
* which would make this deposit unfillable. In this case, the depositor would subsequently receive a refund
584+
* of `inputAmount` of `inputToken` on the origin chain after the fill deadline.
585+
* @dev On the destination chain, the hash of the deposit data will be used to uniquely identify this deposit, so
586+
* modifying any params in it will result in a different hash and a different deposit. The hash will comprise
587+
* all parameters to this function along with this chain's chainId(). Relayers are only refunded for filling
588+
* deposits with deposit hashes that map exactly to the one emitted by this contract.
589+
* @param depositNonce The nonce that uniquely identifies this deposit. This function will combine this parameter
590+
* with the msg.sender address to create a unique uint256 depositNonce and ensure that the msg.sender cannot
591+
* use this function to front-run another depositor's unsafe deposit. This function guarantees that the resultant
592+
* deposit nonce will not collide with a safe uint256 deposit nonce whose 24 most significant bytes are always 0.
593+
* @param depositor See identically named parameter in depositV3() comments.
594+
* @param recipient See identically named parameter in depositV3() comments.
595+
* @param inputToken See identically named parameter in depositV3() comments.
596+
* @param outputToken See identically named parameter in depositV3() comments.
597+
* @param inputAmount See identically named parameter in depositV3() comments.
598+
* @param outputAmount See identically named parameter in depositV3() comments.
599+
* @param destinationChainId See identically named parameter in depositV3() comments.
600+
* @param exclusiveRelayer See identically named parameter in depositV3() comments.
601+
* @param quoteTimestamp See identically named parameter in depositV3() comments.
602+
* @param fillDeadline See identically named parameter in depositV3() comments.
603+
* @param exclusivityPeriod See identically named parameter in depositV3() comments.
604+
* @param message See identically named parameter in depositV3() comments.
605+
*/
606+
function unsafeDepositV3(
607+
address depositor,
608+
address recipient,
609+
address inputToken,
610+
address outputToken,
611+
uint256 inputAmount,
612+
uint256 outputAmount,
613+
uint256 destinationChainId,
614+
address exclusiveRelayer,
615+
uint256 depositNonce,
616+
uint32 quoteTimestamp,
617+
uint32 fillDeadline,
618+
uint32 exclusivityPeriod,
619+
bytes calldata message
620+
) public payable nonReentrant unpausedDeposits {
621+
// @dev Create the uint256 deposit ID by concatenating the msg.sender and depositor address with the inputted
622+
// depositNonce parameter. The resultant 32 byte string will be hashed and then casted to an "unsafe"
623+
// uint256 deposit ID. The probability that the resultant ID collides with a "safe" deposit ID is
624+
// equal to the chance that the first 28 bytes of the hash are 0, which is too small for us to consider.
625+
626+
uint256 depositId = getUnsafeDepositId(msg.sender, depositor, depositNonce);
627+
_depositV3(
604628
depositor,
605629
recipient,
630+
inputToken,
631+
outputToken,
632+
inputAmount,
633+
outputAmount,
634+
destinationChainId,
606635
exclusiveRelayer,
636+
depositId,
637+
quoteTimestamp,
638+
fillDeadline,
639+
uint32(getCurrentTime()) + exclusivityPeriod,
607640
message
608641
);
609642
}
@@ -757,7 +790,7 @@ abstract contract SpokePool is
757790
*/
758791
function speedUpV3Deposit(
759792
address depositor,
760-
uint32 depositId,
793+
uint256 depositId,
761794
uint256 updatedOutputAmount,
762795
address updatedRecipient,
763796
bytes calldata updatedMessage,
@@ -1093,10 +1126,99 @@ abstract contract SpokePool is
10931126
return block.timestamp; // solhint-disable-line not-rely-on-time
10941127
}
10951128

1129+
/**
1130+
* @notice Returns the deposit ID for an unsafe deposit. This function is used to compute the deposit ID
1131+
* in unsafeDepositV3 and is provided as a convenience.
1132+
* @dev msgSenderand depositor are both used as inputs to allow passthrough depositors to create unique
1133+
* deposit hash spaces for unique depositors.
1134+
* @param msgSender The caller of the transaction used as input to produce the deposit ID.
1135+
* @param depositor The depositor address used as input to produce the deposit ID.
1136+
* @param depositNonce The nonce used as input to produce the deposit ID.
1137+
* @return The deposit ID for the unsafe deposit.
1138+
*/
1139+
function getUnsafeDepositId(
1140+
address msgSender,
1141+
address depositor,
1142+
uint256 depositNonce
1143+
) public pure returns (uint256) {
1144+
return uint256(keccak256(abi.encodePacked(msgSender, depositor, depositNonce)));
1145+
}
1146+
10961147
/**************************************
10971148
* INTERNAL FUNCTIONS *
10981149
**************************************/
10991150

1151+
function _depositV3(
1152+
address depositor,
1153+
address recipient,
1154+
address inputToken,
1155+
address outputToken,
1156+
uint256 inputAmount,
1157+
uint256 outputAmount,
1158+
uint256 destinationChainId,
1159+
address exclusiveRelayer,
1160+
uint256 depositId,
1161+
uint32 quoteTimestamp,
1162+
uint32 fillDeadline,
1163+
uint32 exclusivityDeadline,
1164+
bytes calldata message
1165+
) internal {
1166+
// Check that deposit route is enabled for the input token. There are no checks required for the output token
1167+
// which is pulled from the relayer at fill time and passed through this contract atomically to the recipient.
1168+
if (!enabledDepositRoutes[inputToken][destinationChainId]) revert DisabledRoute();
1169+
1170+
// Require that quoteTimestamp has a maximum age so that depositors pay an LP fee based on recent HubPool usage.
1171+
// It is assumed that cross-chain timestamps are normally loosely in-sync, but clock drift can occur. If the
1172+
// SpokePool time stalls or lags significantly, it is still possible to make deposits by setting quoteTimestamp
1173+
// within the configured buffer. The owner should pause deposits/fills if this is undesirable.
1174+
// This will underflow if quoteTimestamp is more than depositQuoteTimeBuffer;
1175+
// this is safe but will throw an unintuitive error.
1176+
1177+
// slither-disable-next-line timestamp
1178+
uint256 currentTime = getCurrentTime();
1179+
if (currentTime - quoteTimestamp > depositQuoteTimeBuffer) revert InvalidQuoteTimestamp();
1180+
1181+
// fillDeadline is relative to the destination chain.
1182+
// Don’t allow fillDeadline to be more than several bundles into the future.
1183+
// This limits the maximum required lookback for dataworker and relayer instances.
1184+
// Also, don't allow fillDeadline to be in the past. This poses a potential UX issue if the destination
1185+
// chain time keeping and this chain's time keeping are out of sync but is not really a practical hurdle
1186+
// unless they are significantly out of sync or the depositor is setting very short fill deadlines. This latter
1187+
// situation won't be a problem for honest users.
1188+
if (fillDeadline < currentTime || fillDeadline > currentTime + fillDeadlineBuffer) revert InvalidFillDeadline();
1189+
1190+
// If the address of the origin token is a wrappedNativeToken contract and there is a msg.value with the
1191+
// transaction then the user is sending the native token. In this case, the native token should be
1192+
// wrapped.
1193+
if (inputToken == address(wrappedNativeToken) && msg.value > 0) {
1194+
if (msg.value != inputAmount) revert MsgValueDoesNotMatchInputAmount();
1195+
wrappedNativeToken.deposit{ value: msg.value }();
1196+
// Else, it is a normal ERC20. In this case pull the token from the caller as per normal.
1197+
// Note: this includes the case where the L2 caller has WETH (already wrapped ETH) and wants to bridge them.
1198+
// In this case the msg.value will be set to 0, indicating a "normal" ERC20 bridging action.
1199+
} else {
1200+
// msg.value should be 0 if input token isn't the wrapped native token.
1201+
if (msg.value != 0) revert MsgValueDoesNotMatchInputAmount();
1202+
IERC20Upgradeable(inputToken).safeTransferFrom(msg.sender, address(this), inputAmount);
1203+
}
1204+
1205+
emit V3FundsDeposited(
1206+
inputToken,
1207+
outputToken,
1208+
inputAmount,
1209+
outputAmount,
1210+
destinationChainId,
1211+
depositId,
1212+
quoteTimestamp,
1213+
fillDeadline,
1214+
exclusivityDeadline,
1215+
depositor,
1216+
recipient,
1217+
exclusiveRelayer,
1218+
message
1219+
);
1220+
}
1221+
11001222
function _deposit(
11011223
address depositor,
11021224
address recipient,
@@ -1223,7 +1345,7 @@ abstract contract SpokePool is
12231345

12241346
function _verifyUpdateV3DepositMessage(
12251347
address depositor,
1226-
uint32 depositId,
1348+
uint256 depositId,
12271349
uint256 originChainId,
12281350
uint256 updatedOutputAmount,
12291351
address updatedRecipient,

contracts/interfaces/V3SpokePoolInterface.sol

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ interface V3SpokePoolInterface {
5353
// Origin chain id.
5454
uint256 originChainId;
5555
// The id uniquely identifying this deposit on the origin chain.
56-
uint32 depositId;
56+
uint256 depositId;
5757
// The timestamp on the destination chain after which this deposit can no longer be filled.
5858
uint32 fillDeadline;
5959
// The timestamp on the destination chain after which any relayer can fill the deposit.
@@ -102,7 +102,7 @@ interface V3SpokePoolInterface {
102102
uint256 inputAmount,
103103
uint256 outputAmount,
104104
uint256 indexed destinationChainId,
105-
uint32 indexed depositId,
105+
uint256 indexed depositId,
106106
uint32 quoteTimestamp,
107107
uint32 fillDeadline,
108108
uint32 exclusivityDeadline,
@@ -114,7 +114,7 @@ interface V3SpokePoolInterface {
114114

115115
event RequestedSpeedUpV3Deposit(
116116
uint256 updatedOutputAmount,
117-
uint32 indexed depositId,
117+
uint256 indexed depositId,
118118
address indexed depositor,
119119
address updatedRecipient,
120120
bytes updatedMessage,
@@ -128,7 +128,7 @@ interface V3SpokePoolInterface {
128128
uint256 outputAmount,
129129
uint256 repaymentChainId,
130130
uint256 indexed originChainId,
131-
uint32 indexed depositId,
131+
uint256 indexed depositId,
132132
uint32 fillDeadline,
133133
uint32 exclusivityDeadline,
134134
address exclusiveRelayer,
@@ -145,7 +145,7 @@ interface V3SpokePoolInterface {
145145
uint256 inputAmount,
146146
uint256 outputAmount,
147147
uint256 indexed originChainId,
148-
uint32 indexed depositId,
148+
uint256 indexed depositId,
149149
uint32 fillDeadline,
150150
uint32 exclusivityDeadline,
151151
address exclusiveRelayer,
@@ -189,7 +189,7 @@ interface V3SpokePoolInterface {
189189

190190
function speedUpV3Deposit(
191191
address depositor,
192-
uint32 depositId,
192+
uint256 depositId,
193193
uint256 updatedOutputAmount,
194194
address updatedRecipient,
195195
bytes calldata updatedMessage,

test/evm/hardhat/SpokePool.Deposit.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
amountReceived,
2626
MAX_UINT32,
2727
originChainId,
28-
zeroAddress,
2928
} from "./constants";
3029

3130
const { AddressZero: ZERO_ADDRESS } = ethers.constants;
@@ -366,6 +365,28 @@ describe("SpokePool Depositor Logic", async function () {
366365
_relayData.message,
367366
];
368367
}
368+
function getUnsafeDepositArgsFromRelayData(
369+
_relayData: V3RelayData,
370+
_depositId: string,
371+
_destinationChainId = destinationChainId,
372+
_quoteTimestamp = quoteTimestamp
373+
) {
374+
return [
375+
_relayData.depositor,
376+
_relayData.recipient,
377+
_relayData.inputToken,
378+
_relayData.outputToken,
379+
_relayData.inputAmount,
380+
_relayData.outputAmount,
381+
_destinationChainId,
382+
_relayData.exclusiveRelayer,
383+
_depositId,
384+
_quoteTimestamp,
385+
_relayData.fillDeadline,
386+
_relayData.exclusivityDeadline,
387+
_relayData.message,
388+
];
389+
}
369390
beforeEach(async function () {
370391
relayData = {
371392
depositor: depositor.address,
@@ -576,6 +597,45 @@ describe("SpokePool Depositor Logic", async function () {
576597
"ReentrancyGuard: reentrant call"
577598
);
578599
});
600+
it("unsafe deposit ID", async function () {
601+
const currentTime = (await spokePool.getCurrentTime()).toNumber();
602+
// new deposit ID should be the uint256 equivalent of the keccak256 hash of packed {msg.sender, depositor, forcedDepositId}.
603+
const forcedDepositId = "99";
604+
const expectedDepositId = BigNumber.from(
605+
ethers.utils.solidityKeccak256(
606+
["address", "address", "uint256"],
607+
[depositor.address, recipient.address, forcedDepositId]
608+
)
609+
);
610+
expect(await spokePool.getUnsafeDepositId(depositor.address, recipient.address, forcedDepositId)).to.equal(
611+
expectedDepositId
612+
);
613+
// Note: we deliberately set the depositor != msg.sender to test that the hashing algorithm correctly includes
614+
// both addresses in the hash.
615+
await expect(
616+
spokePool
617+
.connect(depositor)
618+
.unsafeDepositV3(
619+
...getUnsafeDepositArgsFromRelayData({ ...relayData, depositor: recipient.address }, forcedDepositId)
620+
)
621+
)
622+
.to.emit(spokePool, "V3FundsDeposited")
623+
.withArgs(
624+
relayData.inputToken,
625+
relayData.outputToken,
626+
relayData.inputAmount,
627+
relayData.outputAmount,
628+
destinationChainId,
629+
expectedDepositId,
630+
quoteTimestamp,
631+
relayData.fillDeadline,
632+
currentTime,
633+
recipient.address,
634+
relayData.recipient,
635+
relayData.exclusiveRelayer,
636+
relayData.message
637+
);
638+
});
579639
});
580640
describe("speed up V3 deposit", function () {
581641
const updatedOutputAmount = amountToDeposit.add(1);

0 commit comments

Comments
 (0)