diff --git a/contracts/src/EscrowUniversal.sol b/contracts/src/EscrowUniversal.sol index 9e3123a..7ab98d9 100644 --- a/contracts/src/EscrowUniversal.sol +++ b/contracts/src/EscrowUniversal.sol @@ -10,6 +10,7 @@ pragma solidity 0.8.18; import {IArbitrableV2, IArbitratorV2} from "@kleros/kleros-v2-contracts/arbitration/interfaces/IArbitrableV2.sol"; import "@kleros/kleros-v2-contracts/arbitration/interfaces/IDisputeTemplateRegistry.sol"; import {SafeERC20, IERC20} from "./libraries/SafeERC20.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; import "./interfaces/IEscrow.sol"; /// @title EscrowUniversal for a sale paid in native currency or ERC20 tokens without platform fees. @@ -467,12 +468,13 @@ contract EscrowUniversal is IEscrow, IArbitrableV2 { /// @param _ruling Ruling given by the arbitrator. 1 : Reimburse the seller. 2 : Pay the buyer. function executeRuling(uint256 _transactionID, uint256 _ruling) internal { Transaction storage transaction = transactions[_transactionID]; - uint256 amount = transaction.amount; - uint256 settlementBuyer = transaction.settlementBuyer; - uint256 settlementSeller = transaction.settlementSeller; - uint256 buyerFee = transaction.buyerFee; - uint256 sellerFee = transaction.sellerFee; - bool nativePayment = transaction.token == NATIVE; + address payable buyer = transaction.buyer; + address payable seller = transaction.seller; + + (uint256 buyerPayout, uint256 buyerPayoutToken, uint256 sellerPayout, uint256 sellerPayoutToken) = getPayouts( + _transactionID, + Party(_ruling) + ); transaction.amount = 0; transaction.settlementBuyer = 0; @@ -481,58 +483,17 @@ contract EscrowUniversal is IEscrow, IArbitrableV2 { transaction.sellerFee = 0; transaction.status = Status.TransactionResolved; - // Give the arbitration fee back. - if (_ruling == uint256(Party.Buyer)) { - transaction.buyer.send(buyerFee); - // If there was a settlement amount proposed, we use that to make the partial payment and refund the rest. - if (settlementBuyer != 0) { - if (nativePayment) { - transaction.buyer.send(amount - settlementBuyer); // It is the user responsibility to accept ETH. - transaction.seller.send(settlementBuyer); - } else { - transaction.token.safeTransfer(transaction.buyer, amount - settlementBuyer); - transaction.token.safeTransfer(transaction.seller, settlementBuyer); - } - } else { - if (nativePayment) { - transaction.buyer.send(amount); // It is the user responsibility to accept ETH. - } else { - if (!transaction.token.safeTransfer(transaction.buyer, amount)) revert TokenTransferFailed(); - } - } - } else if (_ruling == uint256(Party.Seller)) { - transaction.seller.send(sellerFee); - // If there was a settlement amount proposed, we use that to make the partial payment and refund the rest to buyer. - if (settlementSeller != 0) { - if (nativePayment) { - transaction.buyer.send(amount - settlementSeller); // It is the user responsibility to accept ETH. - transaction.seller.send(settlementSeller); - } else { - transaction.token.safeTransfer(transaction.buyer, amount - settlementSeller); - transaction.token.safeTransfer(transaction.seller, settlementSeller); - } - } else { - if (nativePayment) { - transaction.seller.send(amount); // It is the user responsibility to accept ETH. - } else { - if (!transaction.token.safeTransfer(transaction.seller, amount)) revert TokenTransferFailed(); - } - } - } else { - uint256 splitArbitrationFee = buyerFee / 2; - transaction.buyer.send(splitArbitrationFee); - transaction.seller.send(splitArbitrationFee); - - // Tokens should not reenter or allow recipients to refuse the transfer. - // In case of an uneven token amount, one basic token unit can be burnt. - uint256 splitAmount = amount / 2; - if (nativePayment) { - transaction.buyer.send(splitAmount); // It is the user responsibility to accept ETH. - transaction.seller.send(splitAmount); - } else { - transaction.token.safeTransfer(transaction.buyer, splitAmount); - transaction.token.safeTransfer(transaction.seller, splitAmount); - } + if (buyerPayout > 0) { + buyer.send(buyerPayout); // It is the user responsibility to accept ETH. + } + if (sellerPayout > 0) { + seller.send(sellerPayout); // It is the user responsibility to accept ETH. + } + if (buyerPayoutToken > 0) { + transaction.token.safeTransfer(buyer, buyerPayoutToken); // Tokens should not reenter or allow recipients to refuse the transfer. + } + if (sellerPayoutToken > 0) { + transaction.token.safeTransfer(seller, sellerPayoutToken); // Tokens should not reenter or allow recipients to refuse the transfer. } emit TransactionResolved(_transactionID, Resolution.RulingEnforced); @@ -546,4 +507,71 @@ contract EscrowUniversal is IEscrow, IArbitrableV2 { function getTransactionCount() external view override returns (uint256) { return transactions.length; } + + /// @dev Get the payout depending on the winning party. + /// @dev The cost for the buyer is the seller payout non-inclusive of any arbitration fees. + /// @param _transactionID The index of the transaction. + /// @param _winningParty The winning party. + /// @return buyerPayout The payout for the buyer. + /// @return buyerPayoutToken The payout for the buyer in tokens. + /// @return sellerPayout The payout for the seller. + /// @return sellerPayoutToken The payout for the seller in tokens. + function getPayouts( + uint256 _transactionID, + Party _winningParty + ) + public + view + returns (uint256 buyerPayout, uint256 buyerPayoutToken, uint256 sellerPayout, uint256 sellerPayoutToken) + { + Transaction storage transaction = transactions[_transactionID]; + uint256 amount = transaction.amount; + uint256 settlementBuyer = transaction.settlementBuyer; + uint256 settlementSeller = transaction.settlementSeller; + uint256 buyerFee = transaction.buyerFee; + uint256 sellerFee = transaction.sellerFee; + bool nativePayment = transaction.token == NATIVE; + if (_winningParty == Party.Buyer) { + // The Seller gets the settlement amount proposed by the Buyer if any, otherwise nothing. + // The Buyer gets the remaining amount of the transaction back if any. + // The Buyer gets the arbitration fee back. + uint256 settledAmount = settlementBuyer; + if (nativePayment) { + buyerPayout = buyerFee + amount - settledAmount; + sellerPayout = settledAmount; + } else { + buyerPayout = buyerFee; + buyerPayoutToken = amount - settledAmount; + sellerPayoutToken = settledAmount; + } + } else if (_winningParty == Party.Seller) { + // The Seller gets his proposed settlement amount if any, otherwise the transaction amount. + // The Buyer gets the remaining amount of the transaction back if any. + // The Seller gets the arbitration fee back. + uint256 settledAmount = settlementSeller != 0 ? settlementSeller : amount; + if (nativePayment) { + buyerPayout = amount - settledAmount; + sellerPayout = sellerFee + settledAmount; + } else { + buyerPayoutToken = amount - settledAmount; + sellerPayout = sellerFee; + sellerPayoutToken = settledAmount; + } + } else { + // No party wins, we split the arbitration fee and the transaction amount. + // The arbitration fee has been paid twice, once by the Buyer and once by the Seller in equal amount once arbitration starts. + // In case of an uneven token amount, one basic token unit can be burnt. + uint256 splitArbitrationFee = buyerFee / 2; // buyerFee equals sellerFee. + buyerPayout = splitArbitrationFee; + sellerPayout = splitArbitrationFee; + uint256 splitAmount = amount / 2; + if (nativePayment) { + buyerPayout += splitAmount; + sellerPayout += splitAmount; + } else { + buyerPayoutToken = splitAmount; + sellerPayoutToken = splitAmount; + } + } + } } diff --git a/contracts/src/EscrowView.sol b/contracts/src/EscrowView.sol new file mode 100644 index 0000000..08f353d --- /dev/null +++ b/contracts/src/EscrowView.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT + +pragma solidity 0.8.18; + +import {Strings} from "@openzeppelin/contracts/utils/Strings.sol"; +import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol"; +import {EscrowUniversal, Transaction, NATIVE, Party, Status, IERC20} from "./EscrowUniversal.sol"; + +contract EscrowView { + EscrowUniversal public immutable escrow; + + constructor(address _escrow) { + escrow = EscrowUniversal(_escrow); + } + + /// @notice Get the payout messages for a transaction once a dispute is created. + /// @notice The amounts pre-dispute are imprecise as the arbitration fees are not paid yet by either or both parties. + function getPayoutMessages( + uint256 _transactionID + ) external view returns (string memory noWinner, string memory buyerWins, string memory sellerWins) { + (, , uint256 amount, , , , , , , , , IERC20 token) = escrow.transactions(_transactionID); + + (uint256 noWinnerPayout, uint256 noWinnerPayoutToken, , ) = escrow.getPayouts(_transactionID, Party.None); + (, , uint256 buyerWinsCost, uint256 buyerWinsCostToken) = escrow.getPayouts(_transactionID, Party.Buyer); + (, , uint256 sellerWinsCost, uint256 sellerWinsCostToken) = escrow.getPayouts(_transactionID, Party.Seller); + + string memory amountStr = formatEth(amount); + string memory amountTokenStr = formatToken(amount, address(token)); + + if (token == NATIVE) { + noWinner = string.concat( + "Buyer and Seller get ", + formatEth(noWinnerPayout), + " back and pay half of the arbitration fees each." + ); + buyerWins = string.concat( + "Buyer pays ", + formatEth(buyerWinsCost), + " instead of ", + amountStr, + ", Seller pays for arbitration." + ); + sellerWins = string.concat( + "Buyer pays ", + formatEth(sellerWinsCost), + " instead of ", + amountStr, + ", Buyer pays for arbitration." + ); + } else { + noWinner = string.concat( + "Buyer and Seller get ", + formatToken(noWinnerPayoutToken, address(token)), + " back and pay half of the arbitration fees each." + ); + buyerWins = string.concat( + "Buyer pays ", + formatToken(buyerWinsCostToken, address(token)), + " instead of ", + amountTokenStr, + ", Seller pays for arbitration." + ); + sellerWins = string.concat( + "Buyer pays ", + formatToken(sellerWinsCostToken, address(token)), + " instead of ", + amountTokenStr, + ", Buyer pays for arbitration." + ); + } + } + + function formatEth(uint256 _amountWei) public pure returns (string memory) { + uint256 ethWhole = _amountWei / 1 ether; + uint256 ethFraction = (_amountWei % 1 ether) / 1e15; // Get the first 3 decimal digits + + // Convert the whole and fractional parts to strings + string memory ethWholeStr = Strings.toString(ethWhole); + + // If the fractional part is zero, return only the whole part + if (ethFraction == 0) { + return string.concat(ethWholeStr, " ETH"); + } + + // Convert the fractional part to string with leading zeros if necessary + string memory ethFractionStr = Strings.toString(ethFraction); + + // Pad the fractional part with leading zeros to ensure three digits + while (bytes(ethFractionStr).length < 3) { + ethFractionStr = string.concat("0", ethFractionStr); + } + + // Remove trailing zeros from the fractional part + bytes memory fractionBytes = bytes(ethFractionStr); + uint256 fractionLength = fractionBytes.length; + while (fractionLength > 0 && fractionBytes[fractionLength - 1] == "0") { + fractionLength--; + } + + if (fractionLength == 0) { + return ethWholeStr; + } else { + bytes memory fractionTrimmed = new bytes(fractionLength); + for (uint256 i = 0; i < fractionLength; i++) { + fractionTrimmed[i] = fractionBytes[i]; + } + return string.concat(ethWholeStr, ".", string(fractionTrimmed), " ETH"); + } + } + + function formatToken(uint256 _amountWei, address _token) public view returns (string memory) { + IERC20Metadata token = IERC20Metadata(_token); + uint8 decimals = token.decimals(); + string memory symbol = token.symbol(); + + uint256 tenToDecimals = uint256(10) ** uint256(decimals); + uint256 tokenWhole = _amountWei / tenToDecimals; + + uint256 tokenFraction; + uint8 fractionDigits; + + if (decimals >= 3) { + uint256 divider = uint256(10) ** uint256(decimals - 3); + tokenFraction = (_amountWei % tenToDecimals) / divider; + fractionDigits = 3; + } else { + tokenFraction = _amountWei % tenToDecimals; + fractionDigits = decimals; + } + + string memory tokenWholeStr = Strings.toString(tokenWhole); + + if (tokenFraction == 0) { + return string.concat(tokenWholeStr, " ", symbol); + } + + string memory tokenFractionStr = Strings.toString(tokenFraction); + + while (bytes(tokenFractionStr).length < fractionDigits) { + tokenFractionStr = string.concat("0", tokenFractionStr); + } + + bytes memory fractionBytes = bytes(tokenFractionStr); + uint256 fractionLength = fractionBytes.length; + while (fractionLength > 0 && fractionBytes[fractionLength - 1] == bytes1("0")) { + fractionLength--; + } + + if (fractionLength == 0) { + return string.concat(tokenWholeStr, " ", symbol); + } else { + bytes memory fractionTrimmed = new bytes(fractionLength); + for (uint256 i = 0; i < fractionLength; i++) { + fractionTrimmed[i] = fractionBytes[i]; + } + return string.concat(tokenWholeStr, ".", string(fractionTrimmed), " ", symbol); + } + } +}