Skip to content

Commit acbc74d

Browse files
Merge remote-tracking branch 'origin/main' into revert-removing-ignore-scripts-in-monitoring
2 parents 2694813 + e678294 commit acbc74d

4 files changed

Lines changed: 1667 additions & 534 deletions

File tree

solidity/contracts/bridge/WalletCoordinator.sol

Lines changed: 322 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
2525
import "./BitcoinTx.sol";
2626
import "./Bridge.sol";
2727
import "./Deposit.sol";
28+
import "./Redemption.sol";
2829
import "./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

Comments
 (0)