Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0c6e437
Implement EVM L2 cross-chain fee rebates
mswilkison Apr 22, 2026
c6070f8
Format EVM L2 fee rebate spec
mswilkison Apr 22, 2026
aed2b3f
Match CI formatting for fee rebate spec
mswilkison Apr 22, 2026
30bfb94
Address Slither findings for L2 rebates
mswilkison Apr 22, 2026
88a2edb
Adjust maintainer refund gas threshold
mswilkison Apr 22, 2026
b470ed2
Address L2 rebate review findings
mswilkison Apr 22, 2026
3f0e9c6
Tighten rebate restoration assertions
mswilkison Apr 22, 2026
d2bd813
fix(wormhole): validate VAA emitterChainId in L1BTCRedeemerWormhole
piotr-roslaniec Apr 23, 2026
f52e42c
fix(staking): bound gas cost in forceStakeTransfer
piotr-roslaniec Apr 23, 2026
5091ee8
fix(l2-rebates): harden EOA-only guard against constructor bypass
piotr-roslaniec Apr 23, 2026
4ca6f28
fix(l2): lock implementation initializers for Wormhole L2 contracts
piotr-roslaniec Apr 23, 2026
05879f5
fix(ntt): use low-level call for native-token recovery
piotr-roslaniec Apr 23, 2026
4286488
test(wormhole): cover VAA emitter-chain validation
piotr-roslaniec Apr 23, 2026
17cabe5
test(staking): cover active-window copy in forceStakeTransfer
piotr-roslaniec Apr 23, 2026
bb4d930
test(l2-rebates): cover constructor bypass on EOA-only guard
piotr-roslaniec Apr 23, 2026
ac6f770
test(l2): cover _disableInitializers on logic contracts
piotr-roslaniec Apr 23, 2026
6226404
test(wormhole): make regression suites executable
piotr-roslaniec Apr 23, 2026
960559b
test: fix CI regressions in audit-fix test additions
piotr-roslaniec Apr 23, 2026
af1bc50
style(contracts): silence solhint on audited low-level constructs
piotr-roslaniec Apr 23, 2026
689d34e
fix(wormhole): close review-surfaced gaps in emitter-chain guard
piotr-roslaniec Apr 23, 2026
7161e4d
test: extend coverage around audit fixes
piotr-roslaniec Apr 23, 2026
575cbe3
fix(pr-952): address audit findings (#953)
piotr-roslaniec Apr 23, 2026
eddf35b
fix(pr-952): harden rebate and refund edge cases
mswilkison Apr 24, 2026
7eadf8a
style: fix PR 952 test formatting
mswilkison Apr 24, 2026
a33fe5f
fix(pr-952): harden cross-chain rebate handling
mswilkison May 21, 2026
739992e
fix(pr-952): prevent timeout refund route overwrite
mswilkison May 21, 2026
fe9f855
fix(pr-952): guard legacy redemption refund routes
mswilkison May 21, 2026
c317a73
chore(pr-952): format solidity files
mswilkison May 21, 2026
a6c090c
docs(pr-952): require setWormhole in enablement runbook
piotr-roslaniec May 26, 2026
d1ab1ad
docs(pr-952): lock in min-rebate skip behavior
piotr-roslaniec May 26, 2026
fcd8eba
feat(pr-952): emit CrossChainRebateCancelRequested entry event
piotr-roslaniec May 26, 2026
505ba48
test(pr-952): cover CrossChainRebateCancelRequested miss paths
piotr-roslaniec May 26, 2026
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
874 changes: 874 additions & 0 deletions docs/cross-chain-fee-waivers-evm-l2-spec.md

Large diffs are not rendered by default.

84 changes: 84 additions & 0 deletions solidity/contracts/bridge/Bridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import "./IRelay.sol";
import "./BridgeState.sol";
import "./Deposit.sol";
import "./DepositSweep.sol";
import "./RebateStaking.sol";
import "./Redemption.sol";
import "./BitcoinTx.sol";
import "./EcdsaLib.sol";
Expand Down Expand Up @@ -243,6 +244,12 @@ contract Bridge is
address oldRebateStaking,
address newRebateStaking
);
event CrossChainIntegratorUpdated(
address indexed integrator,
bool authorized,
uint256 evmSourceChainId,
uint16 wormholeChainId
);

/// @notice Emitted when a deposit's vault field is corrected via governance.
/// @dev This event is used for transparency when fixing deposits that were
Expand Down Expand Up @@ -477,6 +484,24 @@ contract Bridge is
self.revealDepositWithExtraData(fundingTx, reveal, extraData);
}

/// @notice Reveals a P2(W)SH Bitcoin deposit with extra data and applies
/// treasury fee rebate to a separate L1 beneficiary.
function revealDepositWithExtraDataAndRebate(
BitcoinTx.Info calldata fundingTx,
Deposit.DepositRevealInfo calldata reveal,
bytes32 extraData,
address rebateBeneficiary,
RebateStaking.BeneficiaryRebateContext calldata rebateContext
) external {
self.revealDepositWithExtraDataAndRebate(
fundingTx,
reveal,
extraData,
rebateBeneficiary,
rebateContext
);
}

/// @notice Used by the wallet to prove the BTC deposit sweep transaction
/// and to update Bank balances accordingly. Sweep is only accepted
/// if it satisfies SPV proof.
Expand Down Expand Up @@ -580,6 +605,31 @@ contract Bridge is
);
}

/// @notice Requests redemption with a separate refund recipient and rebate
/// beneficiary. Only governance-authorized cross-chain integrators
/// may use this entry point.
function requestRedemptionWithRebate(
bytes20 walletPubKeyHash,
BitcoinTx.UTXO calldata mainUtxo,
address balanceOwner,
address refundRecipient,
bytes calldata redeemerOutputScript,
uint64 amount,
address rebateBeneficiary,
RebateStaking.BeneficiaryRebateContext calldata rebateContext
) external {
self.requestRedemptionWithRebate(
walletPubKeyHash,
mainUtxo,
balanceOwner,
refundRecipient,
redeemerOutputScript,
amount,
rebateBeneficiary,
rebateContext
);
}

/// @notice Requests redemption of the given amount from the specified
/// wallet to the redeemer Bitcoin output script. Used by
/// `Bank.approveBalanceAndCall`. Can handle more complex cases
Expand Down Expand Up @@ -2037,6 +2087,40 @@ contract Bridge is
return self.rebateStaking;
}

/// @notice Updates authorization for an L1 cross-chain integrator.
function setCrossChainIntegrator(
address integrator,
bool authorized,
uint256 evmSourceChainId,
uint16 wormholeChainId
) external onlyGovernance {
self.setCrossChainIntegrator(
integrator,
authorized,
evmSourceChainId,
wormholeChainId
);
}

/// @notice Returns cross-chain integrator configuration.
function crossChainIntegrator(address integrator)
external
view
returns (
bool authorized,
uint256 evmSourceChainId,
uint16 wormholeChainId
)
{
BridgeState.CrossChainIntegrator storage entry = self
.crossChainIntegrators[integrator];
return (
entry.authorized,
entry.evmSourceChainId,
entry.wormholeChainId
);
}

/// @notice Sets the redemption watchtower address.
/// @param redemptionWatchtower Address of the redemption watchtower.
/// @dev Requirements:
Expand Down
19 changes: 19 additions & 0 deletions solidity/contracts/bridge/BridgeGovernance.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1807,4 +1807,23 @@ contract BridgeGovernance is Ownable {
function setRebateStaking(address rebateStaking) external onlyOwner {
bridge.setRebateStaking(rebateStaking);
}

/// @notice Updates authorization for an L1 cross-chain integrator.
/// @param integrator L1 depositor or redeemer contract.
/// @param authorized Whether beneficiary-aware Bridge entry points are allowed.
/// @param evmSourceChainId EVM chain ID of the L2 origin chain.
/// @param wormholeChainId Optional Wormhole chain ID of the L2 origin chain.
function setCrossChainIntegrator(
address integrator,
bool authorized,
uint256 evmSourceChainId,
uint16 wormholeChainId
) external onlyOwner {
bridge.setCrossChainIntegrator(
integrator,
authorized,
evmSourceChainId,
wormholeChainId
);
}
}
58 changes: 57 additions & 1 deletion solidity/contracts/bridge/BridgeState.sol
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ import "./MovingFunds.sol";
import "../bank/Bank.sol";

library BridgeState {
struct CrossChainIntegrator {
bool authorized;
uint256 evmSourceChainId;
uint16 wormholeChainId;
}

struct CrossChainRedemptionRebate {
address rebateBeneficiary;
bytes32 actionId;
}

struct Storage {
// Address of the Bank the Bridge belongs to.
Bank bank;
Expand Down Expand Up @@ -325,14 +336,20 @@ library BridgeState {
// governance wiring; changing it afterwards requires a dedicated
// upgrade path of the Bridge implementation.
address rebateStaking;
// L1 contracts authorized to pass cross-chain rebate context into the
// Bridge. Each entry binds the integrator to an EVM source chain ID.
mapping(address => CrossChainIntegrator) crossChainIntegrators;
// Cross-chain redemption rebate metadata keyed by redemption key. Used
// to cancel rebate capacity by action ID if the redemption times out.
mapping(uint256 => CrossChainRedemptionRebate) crossChainRedemptionRebates;
// 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[46] __gap;
}

event DepositParametersUpdated(
Expand Down Expand Up @@ -393,6 +410,13 @@ library BridgeState {
// parameter events.
event RebateStakingSet(address rebateStaking);

event CrossChainIntegratorUpdated(
address indexed integrator,
bool authorized,
uint256 evmSourceChainId,
uint16 wormholeChainId
);

/// @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 +916,36 @@ library BridgeState {
self.rebateStaking = _rebateStaking;
emit RebateStakingSet(_rebateStaking);
}

/// @notice Updates authorization for an L1 cross-chain integrator.
/// @param integrator L1 depositor or redeemer contract.
/// @param authorized Whether the integrator may use beneficiary-aware paths.
/// @param evmSourceChainId EVM chain ID of the L2 origin chain.
/// @param wormholeChainId Optional Wormhole chain ID for the origin chain.
function setCrossChainIntegrator(
Storage storage self,
address integrator,
bool authorized,
uint256 evmSourceChainId,
uint16 wormholeChainId
) internal {
require(integrator != address(0), "Integrator must not be 0x0");
require(
!authorized || evmSourceChainId != 0,
"Source chain ID must not be 0"
);

self.crossChainIntegrators[integrator] = CrossChainIntegrator(
authorized,
evmSourceChainId,
wormholeChainId
);

emit CrossChainIntegratorUpdated(
integrator,
authorized,
evmSourceChainId,
wormholeChainId
);
}
}
114 changes: 99 additions & 15 deletions solidity/contracts/bridge/Deposit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,24 @@ library Deposit {
BitcoinTx.Info calldata fundingTx,
DepositRevealInfo calldata reveal
) external {
_revealDeposit(self, fundingTx, reveal, bytes32(0));
RebateStaking.BeneficiaryRebateContext
memory emptyContext = RebateStaking.BeneficiaryRebateContext({
sourceChainId: 0,
l2User: address(0),
flowType: RebateStaking.RebateFlowType.Deposit,
actionId: bytes32(0),
maxRebateSat: 0,
authorization: new bytes(0)
});
_revealDeposit(
self,
fundingTx,
reveal,
bytes32(0),
address(0),
emptyContext,
false
);
}

/// @notice Internal function encapsulating the core logic of the deposit
Expand All @@ -194,7 +211,10 @@ library Deposit {
BridgeState.Storage storage self,
BitcoinTx.Info calldata fundingTx,
DepositRevealInfo calldata reveal,
bytes32 extraData
bytes32 extraData,
address rebateBeneficiary,
RebateStaking.BeneficiaryRebateContext memory rebateContext,
bool useCrossChainRebate
) internal {
require(
self.registeredWallets[reveal.walletPubKeyHash].state ==
Expand Down Expand Up @@ -317,15 +337,21 @@ library Deposit {
)
.hash256View();

DepositRequest storage deposit = self.deposits[
uint256(
keccak256(
abi.encodePacked(fundingTxHash, reveal.fundingOutputIndex)
)
uint256 depositKey = uint256(
keccak256(
abi.encodePacked(fundingTxHash, reveal.fundingOutputIndex)
)
];
);
DepositRequest storage deposit = self.deposits[depositKey];
require(deposit.revealedAt == 0, "Deposit already revealed");

if (useCrossChainRebate) {
require(
rebateContext.actionId == bytes32(depositKey),
"Wrong deposit rebate action ID"
);
}

uint64 fundingOutputAmount = fundingOutput.extractValue();

require(
Expand All @@ -344,12 +370,22 @@ library Deposit {
deposit.extraData = extraData;

if (deposit.treasuryFee > 0 && self.rebateStaking != address(0)) {
deposit.treasuryFee = RebateStaking(self.rebateStaking)
.applyForRebate(
deposit.depositor,
deposit.treasuryFee,
RebateStaking.TreasuryFeeType.Deposit
);
if (useCrossChainRebate) {
deposit.treasuryFee = RebateStaking(self.rebateStaking)
.applyForRebateFor(
rebateBeneficiary,
deposit.treasuryFee,
RebateStaking.TreasuryFeeType.Deposit,
rebateContext
);
} else {
deposit.treasuryFee = RebateStaking(self.rebateStaking)
.applyForRebate(
deposit.depositor,
deposit.treasuryFee,
RebateStaking.TreasuryFeeType.Deposit
);
}
}

_emitDepositRevealedEvent(fundingTxHash, fundingOutputAmount, reveal);
Expand Down Expand Up @@ -410,7 +446,55 @@ library Deposit {
// reveal flow and reduce potential attack surface.
require(extraData != bytes32(0), "Extra data must not be empty");

_revealDeposit(self, fundingTx, reveal, extraData);
RebateStaking.BeneficiaryRebateContext
memory emptyContext = RebateStaking.BeneficiaryRebateContext({
sourceChainId: 0,
l2User: address(0),
flowType: RebateStaking.RebateFlowType.Deposit,
actionId: bytes32(0),
maxRebateSat: 0,
authorization: new bytes(0)
});
_revealDeposit(
self,
fundingTx,
reveal,
extraData,
address(0),
emptyContext,
false
);
}

/// @notice Reveals a P2(W)SH Bitcoin deposit with extra data and applies
/// deposit treasury fee rebate to a separate L1 beneficiary.
function revealDepositWithExtraDataAndRebate(
BridgeState.Storage storage self,
BitcoinTx.Info calldata fundingTx,
DepositRevealInfo calldata reveal,
bytes32 extraData,
address rebateBeneficiary,
RebateStaking.BeneficiaryRebateContext calldata rebateContext
) external {
require(extraData != bytes32(0), "Extra data must not be empty");

BridgeState.CrossChainIntegrator storage integrator = self
.crossChainIntegrators[msg.sender];
require(integrator.authorized, "Caller is not cross-chain integrator");
require(
rebateContext.sourceChainId == integrator.evmSourceChainId,
"Wrong rebate source chain"
);

_revealDeposit(
self,
fundingTx,
reveal,
extraData,
rebateBeneficiary,
rebateContext,
true
);
}

/// @notice Validates the deposit refund locktime. The validation passes
Expand Down
Loading
Loading