From 4e5f84b95e16c68fd6398f80f216929e8fb82f94 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 22 May 2026 17:22:41 -0500 Subject: [PATCH 1/2] Route rebate to extraData receiver for sponsored depositors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user mints through a relayer-style depositor contract such as NativeBTCDepositor, the Bridge sees `msg.sender = depositor contract` and applies the RebateStaking lookup against that contract — which has no T stake of its own — instead of the actual L1 receiver encoded in `extraData`. The user's existing rebate budget is bypassed and the full deposit treasury fee (20 bps on mainnet today) is charged. This change adds a governance-managed `sponsoredDepositors` allowlist to `BridgeState`. When `_revealDeposit` is called by an allowlisted depositor and `extraData != bytes32(0)`, the rebate is keyed off the L1 address decoded from `extraData` rather than `msg.sender`. `deposit.depositor` is unchanged so refund and finalize accounting are not affected; delegation through `RebateStaking.getStaker` continues to work transparently. Bridge exposes `setSponsoredDepositor(address, bool)` (onlyGovernance) and `isSponsoredDepositor(address)`. BridgeGovernance gains a matching forwarder following the existing `setRebateStaking` pattern. A new event `SponsoredDepositorSet(address indexed depositor, bool sponsored)` is emitted on allowlist changes. The allowlist consumes one slot from the existing `__gap` (48 → 47) so storage layout remains upgrade-safe. The allowlist is intentionally narrow: only depositors whose `extraData` is provably the L1 address that should be charged belong on it. Today that's `NativeBTCDepositor` only (not yet deployed to mainnet). Cross-chain depositors whose `extraData` is an L2 user identifier (Sui, StarkNet, Arbitrum/Base via Wormhole) must NOT be allowlisted and are handled separately by the cross-chain rebate work in #952. Co-Authored-By: Claude Opus 4.7 (1M context) --- solidity/contracts/bridge/Bridge.sol | 32 ++++ .../contracts/bridge/BridgeGovernance.sol | 13 ++ solidity/contracts/bridge/BridgeState.sol | 32 +++- solidity/contracts/bridge/Deposit.sol | 16 +- solidity/test/bridge/Bridge.Deposit.test.ts | 154 ++++++++++++++++++ .../test/bridge/Bridge.Parameters.test.ts | 83 ++++++++++ 6 files changed, 328 insertions(+), 2 deletions(-) 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..59f1de999 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -344,9 +344,23 @@ 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 ( + extraData != bytes32(0) && self.sponsoredDepositors[msg.sender] + ) { + rebateStaker = address(uint160(uint256(extraData))); + } + 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..f8712d6ab 100644 --- a/solidity/test/bridge/Bridge.Deposit.test.ts +++ b/solidity/test/bridge/Bridge.Deposit.test.ts @@ -1254,6 +1254,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 + }) + }) + }) + }) }) From 3ba6515efadaff406ef894919a1157b34a73af7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Ros=C5=82aniec?= Date: Tue, 26 May 2026 07:15:59 +0000 Subject: [PATCH 2/2] fix(bridge): fail loud on sponsored depositor without extraData Previously the sponsored-depositor branch silently fell back to keying the rebate off the relay contract (which has no stake) if extraData was absent or decoded to the zero address. That regressed the user to the full 20 bps fee without surfacing the misconfiguration. Require an allowlisted depositor to always provide a non-zero extraData that decodes to a non-zero L1 address. NativeBTCDepositor already satisfies this via AbstractBTCDepositor.revealDepositWithExtraData, so production behavior is unchanged; the guard prevents a future allowlist addition from silently regressing. Add a test covering the no-extraData revert path. --- solidity/contracts/bridge/Deposit.sol | 15 +++++++++--- solidity/test/bridge/Bridge.Deposit.test.ts | 27 +++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/solidity/contracts/bridge/Deposit.sol b/solidity/contracts/bridge/Deposit.sol index 59f1de999..8ebcf4d10 100644 --- a/solidity/contracts/bridge/Deposit.sol +++ b/solidity/contracts/bridge/Deposit.sol @@ -352,10 +352,17 @@ library Deposit { // own. `deposit.depositor` itself stays as the relay so refund // and finalize accounting are unchanged. address rebateStaker = deposit.depositor; - if ( - extraData != bytes32(0) && self.sponsoredDepositors[msg.sender] - ) { - rebateStaker = address(uint160(uint256(extraData))); + 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) diff --git a/solidity/test/bridge/Bridge.Deposit.test.ts b/solidity/test/bridge/Bridge.Deposit.test.ts index f8712d6ab..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", () => {