@@ -25,6 +25,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
2525import "./BitcoinTx.sol " ;
2626import "./Bridge.sol " ;
2727import "./Deposit.sol " ;
28+ import "./Redemption.sol " ;
2829import "./Wallets.sol " ;
2930
3031/// @title Wallet coordinator.
@@ -120,6 +121,19 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
120121 bytes4 refundLocktime;
121122 }
122123
124+ /// @notice Helper structure representing a redemption proposal.
125+ struct RedemptionProposal {
126+ // 20-byte public key hash of the target wallet.
127+ bytes20 walletPubKeyHash;
128+ // Array of the redeemers' output scripts that should be part of
129+ // the redemption. Each output script MUST BE prefixed by its byte
130+ // length, i.e. passed in the exactly same format as during the
131+ // `Bridge.requestRedemption` transaction.
132+ bytes [] redeemersOutputScripts;
133+ // Proposed BTC fee for the entire transaction.
134+ uint256 redemptionTxFee;
135+ }
136+
123137 /// @notice Mapping that holds addresses allowed to submit proposals and
124138 /// request heartbeats.
125139 mapping (address => bool ) public isCoordinator;
@@ -195,6 +209,55 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
195209 /// the current conditions.
196210 uint32 public depositSweepProposalSubmissionGasOffset;
197211
212+ /// @notice Determines the redemption proposal validity time. In other
213+ /// words, this is the worst-case time for a redemption during
214+ /// which the wallet is busy and cannot take another actions. This
215+ /// is also the duration of the time lock applied to the wallet
216+ /// once a new redemption proposal is submitted.
217+ ///
218+ /// For example, if a redemption proposal was submitted at
219+ /// 2 pm and redemptionProposalValidity is 2 hours, the next
220+ /// proposal (of any type) can be submitted after 4 pm.
221+ uint32 public redemptionProposalValidity;
222+
223+ /// @notice The minimum time that must elapse since the redemption request
224+ /// creation before a request becomes eligible for a processing.
225+ ///
226+ /// For example, if a request was created at 9 am and
227+ /// redemptionRequestMinAge is 2 hours, the request is eligible for
228+ /// processing after 11 am.
229+ ///
230+ /// @dev Forcing request minimum age ensures block finality for Ethereum.
231+ uint32 public redemptionRequestMinAge;
232+
233+ /// @notice Each redemption request can be technically handled until it
234+ /// reaches its timeout timestamp after which it can be reported
235+ /// as timed out. However, allowing the wallet to handle requests
236+ /// that are close to their timeout timestamp may cause a race
237+ /// between the wallet and the redeemer. In result, the wallet may
238+ /// redeem the requested funds even though the redeemer already
239+ /// received back their tBTC (locked during redemption request) upon
240+ /// reporting the request timeout. In effect, the redeemer may end
241+ /// out with both tBTC and redeemed BTC in their hands which has
242+ /// a negative impact on the tBTC <-> BTC peg. In order to mitigate
243+ /// that problem, this parameter determines a safety margin that
244+ /// puts the latest moment a request can be handled far before the
245+ /// point after which the request can be reported as timed out.
246+ ///
247+ /// For example, if a request times out after 8 pm and
248+ /// redemptionRequestTimeoutSafetyMargin is 2 hours, the request is
249+ /// valid for processing only before 6 pm.
250+ uint32 public redemptionRequestTimeoutSafetyMargin;
251+
252+ /// @notice The maximum count of redemption requests that can be processed
253+ /// within a single redemption.
254+ uint16 public redemptionMaxSize;
255+
256+ /// @notice Gas that is meant to balance the redemption proposal
257+ /// submission overall cost. Can be updated by the owner based on
258+ /// the current conditions.
259+ uint32 public redemptionProposalSubmissionGasOffset;
260+
198261 event CoordinatorAdded (address indexed coordinator );
199262
200263 event CoordinatorRemoved (address indexed coordinator );
@@ -225,6 +288,19 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
225288 address indexed coordinator
226289 );
227290
291+ event RedemptionProposalParametersUpdated (
292+ uint32 redemptionProposalValidity ,
293+ uint32 redemptionRequestMinAge ,
294+ uint32 redemptionRequestTimeoutSafetyMargin ,
295+ uint16 redemptionMaxSize ,
296+ uint32 redemptionProposalSubmissionGasOffset
297+ );
298+
299+ event RedemptionProposalSubmitted (
300+ RedemptionProposal proposal ,
301+ address indexed coordinator
302+ );
303+
228304 modifier onlyCoordinator () {
229305 require (isCoordinator[msg .sender ], "Caller is not a coordinator " );
230306 _;
@@ -259,6 +335,12 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
259335 depositRefundSafetyMargin = 24 hours ;
260336 depositSweepMaxSize = 5 ;
261337 depositSweepProposalSubmissionGasOffset = 20_000 ; // optimized for 10 inputs
338+
339+ redemptionProposalValidity = 2 hours ;
340+ redemptionRequestMinAge = 600 ; // 10 minutes or ~50 blocks.
341+ redemptionRequestTimeoutSafetyMargin = 2 hours ;
342+ redemptionMaxSize = 20 ;
343+ redemptionProposalSubmissionGasOffset = 20_000 ;
262344 }
263345
264346 /// @notice Adds the given address to the set of coordinator addresses.
@@ -467,7 +549,8 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
467549 /// - Each deposit must have valid extra data (see `validateDepositExtraInfo`),
468550 /// - Each deposit must have the refund safety margin preserved,
469551 /// - Each deposit must be controlled by the same wallet,
470- /// - Each deposit must target the same vault.
552+ /// - Each deposit must target the same vault,
553+ /// - Each deposit must be unique.
471554 ///
472555 /// The following off-chain validation must be performed as a bare minimum:
473556 /// - Inputs used for the sweep transaction have enough Bitcoin confirmations,
@@ -498,22 +581,28 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
498581
499582 address proposalVault = address (0 );
500583
584+ uint256 [] memory processedDepositKeys = new uint256 [](
585+ proposal.depositsKeys.length
586+ );
587+
501588 for (uint256 i = 0 ; i < proposal.depositsKeys.length ; i++ ) {
502589 DepositKey memory depositKey = proposal.depositsKeys[i];
503590 DepositExtraInfo memory depositExtraInfo = depositsExtraInfo[i];
504591
505- // slither-disable-next-line calls-loop
506- Deposit.DepositRequest memory depositRequest = bridge.deposits (
507- uint256 (
508- keccak256 (
509- abi.encodePacked (
510- depositKey.fundingTxHash,
511- depositKey.fundingOutputIndex
512- )
592+ uint256 depositKeyUint = uint256 (
593+ keccak256 (
594+ abi.encodePacked (
595+ depositKey.fundingTxHash,
596+ depositKey.fundingOutputIndex
513597 )
514598 )
515599 );
516600
601+ // slither-disable-next-line calls-loop
602+ Deposit.DepositRequest memory depositRequest = bridge.deposits (
603+ depositKeyUint
604+ );
605+
517606 require (depositRequest.revealedAt != 0 , "Deposit not revealed " );
518607
519608 require (
@@ -554,6 +643,16 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
554643 depositRequest.vault == proposalVault,
555644 "Deposit targets different vault "
556645 );
646+
647+ // Make sure there are no duplicates in the deposits list.
648+ for (uint256 j = 0 ; j < i; j++ ) {
649+ require (
650+ processedDepositKeys[j] != depositKeyUint,
651+ "Duplicated deposit "
652+ );
653+ }
654+
655+ processedDepositKeys[i] = depositKeyUint;
557656 }
558657
559658 return true ;
@@ -687,4 +786,218 @@ contract WalletCoordinator is OwnableUpgradeable, Reimbursable {
687786
688787 revert ("Extra info funding output script does not match " );
689788 }
789+
790+ /// @notice Updates parameters related to redemption proposal.
791+ /// @param _redemptionProposalValidity The new value of `redemptionProposalValidity`.
792+ /// @param _redemptionRequestMinAge The new value of `redemptionRequestMinAge`.
793+ /// @param _redemptionRequestTimeoutSafetyMargin The new value of
794+ /// `redemptionRequestTimeoutSafetyMargin`.
795+ /// @param _redemptionMaxSize The new value of `redemptionMaxSize`.
796+ /// @param _redemptionProposalSubmissionGasOffset The new value of
797+ /// `redemptionProposalSubmissionGasOffset`.
798+ /// @dev Requirements:
799+ /// - The caller must be the owner.
800+ function updateRedemptionProposalParameters (
801+ uint32 _redemptionProposalValidity ,
802+ uint32 _redemptionRequestMinAge ,
803+ uint32 _redemptionRequestTimeoutSafetyMargin ,
804+ uint16 _redemptionMaxSize ,
805+ uint32 _redemptionProposalSubmissionGasOffset
806+ ) external onlyOwner {
807+ redemptionProposalValidity = _redemptionProposalValidity;
808+ redemptionRequestMinAge = _redemptionRequestMinAge;
809+ redemptionRequestTimeoutSafetyMargin = _redemptionRequestTimeoutSafetyMargin;
810+ redemptionMaxSize = _redemptionMaxSize;
811+ redemptionProposalSubmissionGasOffset = _redemptionProposalSubmissionGasOffset;
812+
813+ emit RedemptionProposalParametersUpdated (
814+ _redemptionProposalValidity,
815+ _redemptionRequestMinAge,
816+ _redemptionRequestTimeoutSafetyMargin,
817+ _redemptionMaxSize,
818+ _redemptionProposalSubmissionGasOffset
819+ );
820+ }
821+
822+ /// @notice Submits a redemption proposal. Locks the target wallet
823+ /// for a specific time, equal to the proposal validity period.
824+ /// This function does not store the proposal in the state but
825+ /// just emits an event that serves as a guiding light for wallet
826+ /// off-chain members. Wallet members are supposed to validate
827+ /// the proposal on their own, before taking any action.
828+ /// @param proposal The redemption proposal
829+ /// @dev Requirements:
830+ /// - The caller is a coordinator,
831+ /// - The wallet is not time-locked.
832+ function submitRedemptionProposal (RedemptionProposal calldata proposal )
833+ public
834+ onlyCoordinator
835+ onlyAfterWalletLock (proposal.walletPubKeyHash)
836+ {
837+ walletLock[proposal.walletPubKeyHash] = WalletLock (
838+ /* solhint-disable-next-line not-rely-on-time */
839+ uint32 (block .timestamp ) + redemptionProposalValidity,
840+ WalletAction.Redemption
841+ );
842+
843+ emit RedemptionProposalSubmitted (proposal, msg .sender );
844+ }
845+
846+ /// @notice Wraps `submitRedemptionProposal` call and reimburses the
847+ /// caller's transaction cost.
848+ /// @dev See `submitRedemptionProposal` function documentation.
849+ function submitRedemptionProposalWithReimbursement (
850+ RedemptionProposal calldata proposal
851+ ) external {
852+ uint256 gasStart = gasleft ();
853+
854+ submitRedemptionProposal (proposal);
855+
856+ reimbursementPool.refund (
857+ (gasStart - gasleft ()) + redemptionProposalSubmissionGasOffset,
858+ msg .sender
859+ );
860+ }
861+
862+ /// @notice View function encapsulating the main rules of a valid redemption
863+ /// proposal. This function is meant to facilitate the off-chain
864+ /// validation of the incoming proposals. Thanks to it, most
865+ /// of the work can be done using a single readonly contract call.
866+ /// @param proposal The redemption proposal to validate.
867+ /// @return True if the proposal is valid. Reverts otherwise.
868+ /// @dev Requirements:
869+ /// - The target wallet must be in the Live state,
870+ /// - The number of redemption requests included in the redemption
871+ /// proposal must be in the range [1, `redemptionMaxSize`],
872+ /// - The proposed redemption tx fee must be grater than zero,
873+ /// - The proposed redemption tx fee must be lesser than or equal to
874+ /// the maximum total fee allowed by the Bridge
875+ /// (`Bridge.redemptionTxMaxTotalFee`),
876+ /// - The proposed maximum per-request redemption tx fee share must be
877+ /// lesser than or equal to the maximum fee share allowed by the
878+ /// given request (`RedemptionRequest.txMaxFee`),
879+ /// - Each request must be a pending request registered in the Bridge,
880+ /// - Each request must be old enough, i.e. at least `redemptionRequestMinAge`
881+ /// elapsed since their creation time,
882+ /// - Each request must have the timeout safety margin preserved,
883+ /// - Each request must be unique.
884+ function validateRedemptionProposal (RedemptionProposal calldata proposal )
885+ external
886+ view
887+ returns (bool )
888+ {
889+ require (
890+ bridge.wallets (proposal.walletPubKeyHash).state ==
891+ Wallets.WalletState.Live,
892+ "Wallet is not in Live state "
893+ );
894+
895+ uint256 requestsCount = proposal.redeemersOutputScripts.length ;
896+
897+ require (requestsCount > 0 , "Redemption below the min size " );
898+
899+ require (
900+ requestsCount <= redemptionMaxSize,
901+ "Redemption exceeds the max size "
902+ );
903+
904+ (
905+ ,
906+ ,
907+ ,
908+ uint64 redemptionTxMaxTotalFee ,
909+ uint32 redemptionTimeout ,
910+ ,
911+
912+ ) = bridge.redemptionParameters ();
913+
914+ require (
915+ proposal.redemptionTxFee > 0 ,
916+ "Proposed transaction fee cannot be zero "
917+ );
918+
919+ // Make sure the proposed fee does not exceed the total fee limit.
920+ require (
921+ proposal.redemptionTxFee <= redemptionTxMaxTotalFee,
922+ "Proposed transaction fee is too high "
923+ );
924+
925+ // Compute the indivisible remainder that remains after dividing the
926+ // redemption transaction fee over all requests evenly.
927+ uint256 redemptionTxFeeRemainder = proposal.redemptionTxFee %
928+ requestsCount;
929+ // Compute the transaction fee per request by dividing the redemption
930+ // transaction fee (reduced by the remainder) by the number of requests.
931+ uint256 redemptionTxFeePerRequest = (proposal.redemptionTxFee -
932+ redemptionTxFeeRemainder) / requestsCount;
933+
934+ uint256 [] memory processedRedemptionKeys = new uint256 [](requestsCount);
935+
936+ for (uint256 i = 0 ; i < requestsCount; i++ ) {
937+ bytes memory script = proposal.redeemersOutputScripts[i];
938+
939+ // As the wallet public key hash is part of the redemption key,
940+ // we have an implicit guarantee that all requests being part
941+ // of the proposal target the same wallet.
942+ uint256 redemptionKey = uint256 (
943+ keccak256 (
944+ abi.encodePacked (
945+ keccak256 (script),
946+ proposal.walletPubKeyHash
947+ )
948+ )
949+ );
950+
951+ // slither-disable-next-line calls-loop
952+ Redemption.RedemptionRequest memory redemptionRequest = bridge
953+ .pendingRedemptions (redemptionKey);
954+
955+ require (
956+ redemptionRequest.requestedAt != 0 ,
957+ "Not a pending redemption request "
958+ );
959+
960+ require (
961+ /* solhint-disable-next-line not-rely-on-time */
962+ block .timestamp >
963+ redemptionRequest.requestedAt + redemptionRequestMinAge,
964+ "Redemption request min age not achieved yet "
965+ );
966+
967+ // Calculate the timeout the given request times out at.
968+ uint32 requestTimeout = redemptionRequest.requestedAt +
969+ redemptionTimeout;
970+ // Make sure we are far enough from the moment the request times out.
971+ require (
972+ /* solhint-disable-next-line not-rely-on-time */
973+ block .timestamp <
974+ requestTimeout - redemptionRequestTimeoutSafetyMargin,
975+ "Redemption request timeout safety margin is not preserved "
976+ );
977+
978+ uint256 feePerRequest = redemptionTxFeePerRequest;
979+ // The last request incurs the fee remainder.
980+ if (i == requestsCount - 1 ) {
981+ feePerRequest += redemptionTxFeeRemainder;
982+ }
983+ // Make sure the redemption transaction fee share incurred by
984+ // the given request fits in the limit for that request.
985+ require (
986+ feePerRequest <= redemptionRequest.txMaxFee,
987+ "Proposed transaction per-request fee share is too high "
988+ );
989+
990+ // Make sure there are no duplicates in the requests list.
991+ for (uint256 j = 0 ; j < i; j++ ) {
992+ require (
993+ processedRedemptionKeys[j] != redemptionKey,
994+ "Duplicated request "
995+ );
996+ }
997+
998+ processedRedemptionKeys[i] = redemptionKey;
999+ }
1000+
1001+ return true ;
1002+ }
6901003}
0 commit comments