Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions solidity/contracts/bridge/Bridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions solidity/contracts/bridge/BridgeGovernance.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
32 changes: 31 additions & 1 deletion solidity/contracts/bridge/BridgeState.sol
Original file line number Diff line number Diff line change
Expand Up @@ -325,14 +325,21 @@ 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
// planned upgrades of the Bridge contract. If more entires are added to
// 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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
23 changes: 22 additions & 1 deletion solidity/contracts/bridge/Deposit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
181 changes: 181 additions & 0 deletions solidity/test/bridge/Bridge.Deposit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading