UniswapXのdirect fillを理解する
目的
- UniswapXのdirectFillの動作について、コードレベルで理解する
- 特に、UniswapXを介して提出されたintentの署名はどのようにオンチェーンで使われるのか。その署名データがfront-runningされることはないのか。
- dutch auctionはオフチェーンで行われてると思うが、その仕様もあればみたい
結果
特に、UniswapXを介して提出されたintentの署名はどのようにオンチェーンで使われるのか。その署名データがfront-runningされることはないのか。
direct fillでswapするトークンの送り先(filler)とswapされたトークンの送り先(swapper)が、directFill関数の引数のsignedOrder.order
から生成されており、signedOrder.order
の生成のされ方ではfront-runされる気がした。
攻撃の手法として、「悪意のあるFillerがintentを横取りして自身のFillにする=Fillerのアドレスの入れ替え」「tokenを受け取るだけして送信しない = Swapperのアドレスの入れ替え(これはfront-runningより単純な攻撃)」2パターンある。
-
Fillerのアドレスの入れ替え
- ExclusiveDutchOrderに関しては署名にExclusiveFillerを入れている。
- また、swapperからfillerにトークンを送信するtransferInputTokensにおいて、fillerが生成するデータであろうorder.orderから生成されるorder.hashと、swapperの署名であるorder.sigが渡され、それら2つの値の検証が行われている。
- このことから、署名の改竄は事実上不可能なのでFillerの書き換えは不可能
-
Swapperのアドレスの入れ替え
- こちらも署名データにrecipientが登録されており、前処理で検証がされてるから入れ替えは不可能
- 関数的に密結合になっているのでミスを犯しやすいが、gas最適化のためにはこの書き方になるよな。。。
-
- DutchOrderに関しては要調査&まだdeployされてなさそう
dutch auctionはオフチェーンで行われてると思うが、その仕様もあればみたい
decay関数でオンチェーンに取り込まれた際の額をベースにamountを決定しているから、オフチェーンで約定しているわけではなさそう。ここは勘違いしていた。
UniswapXの起動方法
- UniswapXを起動してUSED-Pepeなど流動性の低い取引をするとあわられる
Intentの署名
署名はPermit2で利用されているPermitWitnessTransferFrom
が利用されている。
実際に署名するデータの形はこれ
"orderInfo": {
"chainId": 1,
"permit2Address": "0x000000000022d473030f116ddee9f6b43ac78ba3",
"reactor": "0x6000da47483062A0D734Ba3dc7576Ce6A0B645C4",
"swapper": "0x0000000000000000000000000000000000000000",
"nonce": "1993350209834725680308575292465150260730647098062962750049345504775310970881",
"deadline": 1691176812,
"additionalValidationContract": "0x0000000000000000000000000000000000000000",
"additionalValidationData": "0x",
"decayStartTime": 1691176740,
"decayEndTime": 1691176800,
"exclusiveFiller": "0x165D98de005d2818176B99B1A93b9325dBE58181",
"exclusivityOverrideBps": "100",
"input": {
"token": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
"startAmount": "1000000000000000000",
"endAmount": "1000000000000000000"
},
"outputs": [
{
"token": "0x6B175474E89094C44Da98b954EedeAC495271d0F",
"startAmount": "929502510517534478575",
"endAmount": "919795986077127665276",
"recipient": "0x0000000000000000000000000000000000000000"
}
]
}
direct fillの実行
実行しているコントラクト
/// @inheritdoc IReactor
function execute(SignedOrder calldata order) external payable override nonReentrant {
ResolvedOrder[] memory resolvedOrders = new ResolvedOrder[](1);
resolvedOrders[0] = resolve(order);
_prepare(resolvedOrders);
_fill(resolvedOrders);
}
SignerdOrderの型:https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/base/ReactorStructs.sol#L54-L57
/// @dev external struct including a generic encoded order and swapper signature
/// The order bytes will be parsed and mapped to a ResolvedOrder in the concrete reactor contract
struct SignedOrder {
bytes order;
bytes sig;
}
_prepare()と_fill()が実行されている
prepare()
/// @notice validates, injects fees, and transfers input tokens in preparation for order fill
/// @param orders The orders to prepare
function _prepare(ResolvedOrder[] memory orders) internal {
uint256 ordersLength = orders.length;
unchecked {
for (uint256 i = 0; i < ordersLength; i++) {
ResolvedOrder memory order = orders[i];
_injectFees(order);
order.validate(msg.sender);
transferInputTokens(order, msg.sender);
}
}
}
validate()
ResolvedOrderの型:https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/base/ReactorStructs.sol#L43-L50
/// @dev generic concrete order that specifies exact tokens which need to be sent and received
struct ResolvedOrder {
OrderInfo info;
InputToken input;
OutputToken[] outputs;
bytes sig;
bytes32 hash;
}
/// @dev generic order information
/// should be included as the first field in any concrete order types
struct OrderInfo {
// The address of the reactor that this order is targeting
// Note that this must be included in every order so the swapper
// signature commits to the specific reactor that they trust to fill their order properly
IReactor reactor;
// The address of the user which created the order
// Note that this must be included so that order hashes are unique by swapper
address swapper;
// The nonce of the order, allowing for signature replay protection and cancellation
uint256 nonce;
// The timestamp after which this order is no longer valid
uint256 deadline;
// Custom validation contract
IValidationCallback additionalValidationContract;
// Encoded validation params for additionalValidationContract
bytes additionalValidationData;
}
function validate(ResolvedOrder memory resolvedOrder, address filler) internal view {
if (address(this) != address(resolvedOrder.info.reactor)) {
revert InvalidReactor();
}
if (block.timestamp > resolvedOrder.info.deadline) {
revert DeadlinePassed();
}
if (address(resolvedOrder.info.additionalValidationContract) != address(0)) {
resolvedOrder.info.additionalValidationContract.validate(filler, resolvedOrder);
}
}
transferInputTokens
transferInputTokens : https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/reactors/DutchOrderReactor.sol#L47-L56
/// @inheritdoc BaseReactor
function transferInputTokens(ResolvedOrder memory order, address to) internal override {
permit2.permitWitnessTransferFrom(
order.toPermit(),
order.transferDetails(to),
order.info.swapper,
order.hash,
DutchOrderLib.PERMIT2_ORDER_TYPE,
order.sig
);
}
permitWitnessTransferFrom : https://github.com/Uniswap/permit2/blob/main/src/SignatureTransfer.sol#L31-L43
/// @inheritdoc ISignatureTransfer
function permitWitnessTransferFrom(
PermitTransferFrom memory permit,
SignatureTransferDetails calldata transferDetails,
address owner,
bytes32 witness,
string calldata witnessTypeString,
bytes calldata signature
) external {
_permitTransferFrom(
permit, transferDetails, owner, permit.hashWithWitness(witness, witnessTypeString), signature
);
}
/// @notice Transfers a token using a signed permit message.
/// @param permit The permit data signed over by the owner
/// @param dataHash The EIP-712 hash of permit data to include when checking signature
/// @param owner The owner of the tokens to transfer
/// @param transferDetails The spender's requested transfer details for the permitted token
/// @param signature The signature to verify
function _permitTransferFrom(
PermitTransferFrom memory permit,
SignatureTransferDetails calldata transferDetails,
address owner,
bytes32 dataHash,
bytes calldata signature
) private {
uint256 requestedAmount = transferDetails.requestedAmount;
if (block.timestamp > permit.deadline) revert SignatureExpired(permit.deadline);
if (requestedAmount > permit.permitted.amount) revert InvalidAmount(permit.permitted.amount);
_useUnorderedNonce(owner, permit.nonce);
signature.verify(_hashTypedData(dataHash), owner);
ERC20(permit.permitted.token).safeTransferFrom(owner, transferDetails.to, requestedAmount);
}
この時点でswapperからのapprove+transferFromが行われる。
関数の処理を見ていると、transferInputTokens(order, msg.sender);
で送り先を指定しているので、toを変更しようと思えば変更できる可能性あり。
resolve
その前に引数が整形されていたのでその関数
resolve : https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/reactors/DutchOrderReactor.sol#L27-L44
署名はsignedOrder.sigがそのまま使われている
orderはsignedOrder.orderをdecodeしている。
つまり、signedOrder.orderにorderの情報が含まれており、signedOrder.sigはおそらくSwapperの署名
function resolve(SignedOrder calldata signedOrder)
internal
view
virtual
override
returns (ResolvedOrder memory resolvedOrder)
{
DutchOrder memory order = abi.decode(signedOrder.order, (DutchOrder));
_validateOrder(order);
resolvedOrder = ResolvedOrder({
info: order.info,
input: order.input.decay(order.decayStartTime, order.decayEndTime),
outputs: order.outputs.decay(order.decayStartTime, order.decayEndTime),
sig: signedOrder.sig,
hash: order.hash()
});
}
DutchOrder : https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/lib/DutchOrderLib.sol#L30-L41
struct DutchOrder {
// generic order information
OrderInfo info;
// The time at which the DutchOutputs start decaying
uint256 decayStartTime;
// The time at which price becomes static
uint256 decayEndTime;
// The tokens that the swapper will provide when settling the order
DutchInput input;
// The tokens that must be received to satisfy the order
DutchOutput[] outputs;
}
DutchOutput / DutchInput : https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/lib/DutchOrderLib.sol#L8-L28
/// @dev An amount of output tokens that decreases linearly over time
struct DutchOutput {
// The ERC20 token address (or native ETH address)
address token;
// The amount of tokens at the start of the time period
uint256 startAmount;
// The amount of tokens at the end of the time period
uint256 endAmount;
// The address who must receive the tokens to satisfy the order
address recipient;
}
/// @dev An amount of input tokens that increases linearly over time
struct DutchInput {
// The ERC20 token address
ERC20 token;
// The amount of tokens at the start of the time period
uint256 startAmount;
// The amount of tokens at the end of the time period
uint256 endAmount;
}
decay : https://github.com/Uniswap/UniswapX/blob/4bc3d8d2f528223a0d6df5ac5348510883e9aa77/src/lib/DutchDecayLib.sol#L20-L48C6
ここでdutchauctionを実装している
/// @notice calculates an amount using linear decay over time from decayStartTime to decayEndTime
/// @dev handles both positive and negative decay depending on startAmount and endAmount
/// @param startAmount The amount of tokens at decayStartTime
/// @param endAmount The amount of tokens at decayEndTime
/// @param decayStartTime The time to start decaying linearly
/// @param decayEndTime The time to stop decaying linearly
function decay(uint256 startAmount, uint256 endAmount, uint256 decayStartTime, uint256 decayEndTime)
internal
view
returns (uint256 decayedAmount)
{
if (decayEndTime < decayStartTime) {
revert EndTimeBeforeStartTime();
} else if (decayEndTime <= block.timestamp) {
decayedAmount = endAmount;
} else if (decayStartTime >= block.timestamp) {
decayedAmount = startAmount;
} else {
unchecked {
uint256 elapsed = block.timestamp - decayStartTime;
uint256 duration = decayEndTime - decayStartTime;
if (endAmount < startAmount) {
decayedAmount = startAmount - (startAmount - endAmount).mulDivDown(elapsed, duration);
} else {
decayedAmount = startAmount + (endAmount - startAmount).mulDivDown(elapsed, duration);
}
}
}
}
fill
/// @notice fills a list of orders, ensuring all outputs are satisfied
/// @param orders The orders to fill
function _fill(ResolvedOrder[] memory orders) internal {
uint256 ordersLength = orders.length;
// attempt to transfer all currencies to all recipients
unchecked {
// transfer output tokens to their respective recipients
for (uint256 i = 0; i < ordersLength; i++) {
ResolvedOrder memory resolvedOrder = orders[i];
uint256 outputsLength = resolvedOrder.outputs.length;
for (uint256 j = 0; j < outputsLength; j++) {
OutputToken memory output = resolvedOrder.outputs[j];
output.token.transferFill(output.recipient, output.amount);
}
emit Fill(orders[i].hash, msg.sender, resolvedOrder.info.swapper, resolvedOrder.info.nonce);
}
}
// refund any remaining ETH to the filler. Only occurs when filler sends more ETH than required to
// `execute()` or `executeBatch()`, or when there is excess contract balance remaining from others
// incorrectly calling execute/executeBatch without direct filler method but with a msg.value
if (address(this).balance > 0) {
CurrencyLibrary.transferNative(msg.sender, address(this).balance);
}
}
output.recipient
はsignedOrder.orderをdecodeしたものなので、fillerが任意に指定できそう。