diff --git a/solidity/contracts/bridge/Bridge.sol b/solidity/contracts/bridge/Bridge.sol index 028642ae3..8a8d6565b 100644 --- a/solidity/contracts/bridge/Bridge.sol +++ b/solidity/contracts/bridge/Bridge.sol @@ -244,6 +244,8 @@ contract Bridge is address newRebateStaking ); + event SponsoredDepositorSet(address indexed depositor, bool sponsored); + /// @notice Emitted when a deposit's vault field is corrected via governance. /// @dev This event is used for transparency when fixing deposits that were /// revealed with incorrect vault targets. @@ -2037,6 +2039,36 @@ contract Bridge is return self.rebateStaking; } + /// @notice Adds or removes a depositor contract from the sponsored + /// depositor allowlist. When a depositor is on the list, reveals + /// it submits route the `RebateStaking` rebate to the L1 address + /// decoded from `extraData` rather than to the depositor contract + /// itself. Intended for direct-L1-receiver relays such as + /// `NativeBTCDepositor`. Must not be enabled for cross-chain + /// depositors whose `extraData` is an L2 user identifier rather + /// than an L1 staker. + /// @param depositor Address of the depositor contract. + /// @param sponsored New allowlist membership. + /// @dev Requirements: + /// - The caller must be the governance, + /// - Depositor address must not be 0x0. + function setSponsoredDepositor(address depositor, bool sponsored) + external + onlyGovernance + { + self.setSponsoredDepositor(depositor, sponsored); + } + + /// @notice Returns whether `depositor` is on the sponsored depositor + /// allowlist. + function isSponsoredDepositor(address depositor) + external + view + returns (bool) + { + return self.sponsoredDepositors[depositor]; + } + /// @notice Sets the redemption watchtower address. /// @param redemptionWatchtower Address of the redemption watchtower. /// @dev Requirements: diff --git a/solidity/contracts/bridge/BridgeGovernance.sol b/solidity/contracts/bridge/BridgeGovernance.sol index b1a6f206a..9067abfd5 100644 --- a/solidity/contracts/bridge/BridgeGovernance.sol +++ b/solidity/contracts/bridge/BridgeGovernance.sol @@ -1807,4 +1807,17 @@ contract BridgeGovernance is Ownable { function setRebateStaking(address rebateStaking) external onlyOwner { bridge.setRebateStaking(rebateStaking); } + + /// @notice Forwards a sponsored-depositor allowlist toggle to the + /// underlying Bridge implementation. + /// @param depositor Address of the depositor contract. + /// @param sponsored New allowlist membership. + /// @dev Requirements: + /// - The caller must be the owner. + function setSponsoredDepositor(address depositor, bool sponsored) + external + onlyOwner + { + bridge.setSponsoredDepositor(depositor, sponsored); + } } diff --git a/solidity/contracts/bridge/BridgeState.sol b/solidity/contracts/bridge/BridgeState.sol index 648e11069..547302600 100644 --- a/solidity/contracts/bridge/BridgeState.sol +++ b/solidity/contracts/bridge/BridgeState.sol @@ -325,6 +325,13 @@ library BridgeState { // governance wiring; changing it afterwards requires a dedicated // upgrade path of the Bridge implementation. address rebateStaking; + // Set of relayer-style depositor contracts whose `extraData` should be + // interpreted as the L1 rebate staker for reveals they submit on + // behalf of an L1 receiver. Governance-managed; intended for direct + // L1-receiver depositors (e.g. NativeBTCDepositor) and must not + // include cross-chain depositors whose `extraData` is an L2 user + // identifier. + mapping(address => bool) sponsoredDepositors; // Reserved storage space in case we need to add more variables. // The convention from OpenZeppelin suggests the storage space should // add up to 50 slots. Here we want to have more slots as there are @@ -332,7 +339,7 @@ library BridgeState { // the struct in the upcoming versions we need to reduce the array size. // See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps // slither-disable-next-line unused-state - uint256[48] __gap; + uint256[47] __gap; } event DepositParametersUpdated( @@ -393,6 +400,10 @@ library BridgeState { // parameter events. event RebateStakingSet(address rebateStaking); + // Event emitted when governance adds or removes an entry from the + // `sponsoredDepositors` allowlist. `sponsored` reflects the new state. + event SponsoredDepositorSet(address indexed depositor, bool sponsored); + /// @notice Updates parameters of deposits. /// @param _depositDustThreshold New value of the deposit dust threshold in /// satoshis. It is the minimal amount that can be requested to @@ -892,4 +903,23 @@ library BridgeState { self.rebateStaking = _rebateStaking; emit RebateStakingSet(_rebateStaking); } + + /// @notice Adds or removes a depositor contract from the sponsored + /// depositor allowlist. Reveals submitted by an allowlisted + /// depositor have their rebate routed to the L1 address decoded + /// from `extraData` instead of to the depositor contract. + /// @param _depositor Address of the depositor contract. + /// @param _sponsored New allowlist membership. + /// @dev Requirements: + /// - Depositor address must not be 0x0. + function setSponsoredDepositor( + Storage storage self, + address _depositor, + bool _sponsored + ) internal { + require(_depositor != address(0), "Depositor address must not be 0x0"); + + self.sponsoredDepositors[_depositor] = _sponsored; + emit SponsoredDepositorSet(_depositor, _sponsored); + } } diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 6086f2d4e..8ebcf4d10 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -344,9 +344,30 @@ library Deposit { deposit.extraData = extraData; if (deposit.treasuryFee > 0 && self.rebateStaking != address(0)) { + // By default the rebate is keyed off the depositor (msg.sender). + // When the depositor is an allowlisted "sponsored" relay (e.g. + // NativeBTCDepositor) and an `extraData` payload is present, + // route the rebate to the L1 receiver encoded in `extraData` + // instead of to the relay contract, which has no stake of its + // own. `deposit.depositor` itself stays as the relay so refund + // and finalize accounting are unchanged. + address rebateStaker = deposit.depositor; + if (self.sponsoredDepositors[msg.sender]) { + require( + extraData != bytes32(0), + "Sponsored depositor must provide extraData" + ); + address decoded = address(uint160(uint256(extraData))); + require( + decoded != address(0), + "Sponsored extraData decodes to zero" + ); + rebateStaker = decoded; + } + deposit.treasuryFee = RebateStaking(self.rebateStaking) .applyForRebate( - deposit.depositor, + rebateStaker, deposit.treasuryFee, RebateStaking.TreasuryFeeType.Deposit ); diff --git a/solidity/test/bridge/Bridge.Deposit.test.ts b/solidity/test/bridge/Bridge.Deposit.test.ts index 3e1450c2a..61f7d7ecf 100644 --- a/solidity/test/bridge/Bridge.Deposit.test.ts +++ b/solidity/test/bridge/Bridge.Deposit.test.ts @@ -473,6 +473,33 @@ describe("Bridge - Deposit", () => { }) } ) + + context( + "when depositor is on the sponsored depositor allowlist", + () => { + before(async () => { + await createSnapshot() + + await bridgeGovernance + .connect(governance) + .setSponsoredDepositor(depositor.address, true) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should revert because extraData is required", async () => { + await expect( + bridge + .connect(depositor) + .revealDeposit(P2SHFundingTx, reveal) + ).to.be.revertedWith( + "Sponsored depositor must provide extraData" + ) + }) + } + ) }) context("when deposit is not routed to a vault", () => { @@ -1254,6 +1281,160 @@ describe("Bridge - Deposit", () => { }) }) + context( + "when depositor is on the sponsored depositor allowlist", + () => { + // The fixture's `extraData` is a fixed 32-byte value + // embedded in the Bitcoin script; its low 20 bytes give + // the L1 receiver address whose stake should be + // honored when the depositor is on the allowlist. + const receiverAddress = ethers.utils.getAddress( + `0x${extraData.slice(-40)}` + ) + const stakeAmount = to1e18(5) + + let receiver: SignerWithAddress + let depositorAvailableBefore: BigNumber + let receiverAvailableBefore: BigNumber + + before(async () => { + await createSnapshot() + + // Allowlist the depositor that the existing fixture + // already impersonates as the reveal caller. Goes + // through `BridgeGovernance` because Bridge governance + // is still held by that contract in this fixture. + await bridgeGovernance + .connect(governance) + .setSponsoredDepositor(depositor.address, true) + + // Fund the receiver-impersonating account with ETH so + // it can pay gas, then mint and stake T from it. + receiver = await impersonateAccount(receiverAddress, { + from: governance, + value: 10, + }) + await t + .connect(deployer) + .mint(receiver.address, stakeAmount) + await t + .connect(receiver) + .approve(rebateStaking.address, stakeAmount) + await rebateStaking.connect(receiver).stake(stakeAmount) + + depositorAvailableBefore = + await rebateStaking.getAvailableRebate( + depositor.address + ) + receiverAvailableBefore = + await rebateStaking.getAvailableRebate( + receiver.address + ) + + await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should consume rebate from the receiver, not the depositor", async () => { + const depositorAvailableAfter = + await rebateStaking.getAvailableRebate( + depositor.address + ) + const receiverAvailableAfter = + await rebateStaking.getAvailableRebate( + receiver.address + ) + + expect(receiverAvailableAfter).to.be.lt( + receiverAvailableBefore + ) + expect(depositorAvailableAfter).to.equal( + depositorAvailableBefore + ) + }) + + it("should still record deposit.depositor as the depositor contract", async () => { + const depositKey = ethers.utils.solidityKeccak256( + ["bytes32", "uint32"], + [ + "0x6383cd1829260b6034cd12bad36171748e8c3c6a8d57fcb6463c62f96116dfbc", + reveal.fundingOutputIndex, + ] + ) + const deposit = await bridge.deposits(depositKey) + expect(deposit.depositor).to.equal(depositor.address) + expect(deposit.extraData).to.equal(extraData) + }) + } + ) + + context( + "when depositor is not on the sponsored depositor allowlist", + () => { + // Same setup as the sponsored case but without + // allowlisting the depositor. The rebate should fall + // back to the depositor identity (unchanged behavior), + // and the L1 receiver's stake should be untouched. + const receiverAddress = ethers.utils.getAddress( + `0x${extraData.slice(-40)}` + ) + const stakeAmount = to1e18(5) + + let receiver: SignerWithAddress + let receiverAvailableBefore: BigNumber + + before(async () => { + await createSnapshot() + + receiver = await impersonateAccount(receiverAddress, { + from: governance, + value: 10, + }) + await t + .connect(deployer) + .mint(receiver.address, stakeAmount) + await t + .connect(receiver) + .approve(rebateStaking.address, stakeAmount) + await rebateStaking.connect(receiver).stake(stakeAmount) + + receiverAvailableBefore = + await rebateStaking.getAvailableRebate( + receiver.address + ) + + await bridge + .connect(depositor) + .revealDepositWithExtraData( + P2SHFundingTx, + reveal, + extraData + ) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should leave the receiver's rebate untouched", async () => { + expect( + await rebateStaking.getAvailableRebate( + receiver.address + ) + ).to.equal(receiverAvailableBefore) + }) + } + ) + context("when deposit is not routed to a vault", () => { let tx: ContractTransaction let nonRoutedReveal: DepositRevealInfoStruct diff --git a/solidity/test/bridge/Bridge.Parameters.test.ts b/solidity/test/bridge/Bridge.Parameters.test.ts index f8ac55fb7..f54014930 100644 --- a/solidity/test/bridge/Bridge.Parameters.test.ts +++ b/solidity/test/bridge/Bridge.Parameters.test.ts @@ -2003,4 +2003,87 @@ describe("Bridge - Parameters", () => { }) }) }) + + describe("setSponsoredDepositor", () => { + const sponsoredDepositor = "0x1111111111111111111111111111111111111111" + + context("when caller is not the contract guvnor", () => { + it("should revert", async () => { + await expect( + bridge + .connect(thirdParty) + .setSponsoredDepositor(sponsoredDepositor, true) + ).to.be.revertedWith("Caller is not the governance") + }) + }) + + context("when caller is the contract guvnor", () => { + before(async () => { + await createSnapshot() + + // Mirror the pattern used by `setRebateStaking`: transfer Bridge + // governance to a simple EOA so we can call the Bridge entrypoint + // directly in the rest of the suite. + await bridgeGovernance + .connect(governance) + .beginBridgeGovernanceTransfer(governance.address) + await helpers.time.increaseTime(constants.governanceDelay) + await bridgeGovernance + .connect(governance) + .finalizeBridgeGovernanceTransfer() + }) + + after(async () => { + await restoreSnapshot() + }) + + context("when the depositor address is zero", () => { + it("should revert", async () => { + await expect( + bridge.connect(governance).setSponsoredDepositor(ZERO_ADDRESS, true) + ).to.be.revertedWith("Depositor address must not be 0x0") + }) + }) + + context("when the depositor address is non-zero", () => { + let tx: ContractTransaction + + before(async () => { + await createSnapshot() + + tx = await bridge + .connect(governance) + .setSponsoredDepositor(sponsoredDepositor, true) + }) + + after(async () => { + await restoreSnapshot() + }) + + it("should add the depositor to the allowlist", async () => { + expect(await bridge.isSponsoredDepositor(sponsoredDepositor)).to.be + .true + }) + + it("should emit SponsoredDepositorSet event", async () => { + await expect(tx) + .to.emit(bridge, "SponsoredDepositorSet") + .withArgs(sponsoredDepositor, true) + }) + + it("should allow toggling the allowlist off again", async () => { + await expect( + bridge + .connect(governance) + .setSponsoredDepositor(sponsoredDepositor, false) + ) + .to.emit(bridge, "SponsoredDepositorSet") + .withArgs(sponsoredDepositor, false) + + expect(await bridge.isSponsoredDepositor(sponsoredDepositor)).to.be + .false + }) + }) + }) + }) })