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
27 changes: 18 additions & 9 deletions solidity/contracts/bridge/RebateStaking.sol
Original file line number Diff line number Diff line change
Expand Up @@ -975,17 +975,26 @@ contract RebateStaking is Initializable, OwnableUpgradeable {
newStake.unstakingAmount = oldStake.unstakingAmount;
newStake.unstakingTimestamp = oldStake.unstakingTimestamp;
newStake.rebateTreasuryFeeMode = oldStake.rebateTreasuryFeeMode;
// Only migrate the active rebate window. Anything before
// `rollingWindowStartIndex` has aged out and is never read again, so
// copying it would grow gas cost without bound for long-lived
// stakers. The copied window starts at index 0 in the new array.
// Only migrate the active rebate window. The start index is updated
// lazily during rebate operations, so use timestamps to find the first
// active entry even for stakers that have been inactive for a long time.
newStake.rollingWindowStartIndex = 0;
uint256 rebatesLength = oldStake.rebates.length;
for (
uint256 i = oldStake.rollingWindowStartIndex;
i < rebatesLength;
i++
) {

/* solhint-disable-next-line not-rely-on-time */
uint256 windowStart = block.timestamp - rollingWindow;
uint256 low = oldStake.rollingWindowStartIndex;
uint256 high = rebatesLength;
while (low < high) {
uint256 mid = (low + high) / 2;
if (oldStake.rebates[mid].timestamp < windowStart) {
low = mid + 1;
} else {
high = mid;
}
}

for (uint256 i = low; i < rebatesLength; i++) {
newStake.rebates.push(oldStake.rebates[i]);
}
if (oldStake.delegatee != address(0)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ contract L1BTCRedeemerWormhole is
address l2User;
uint256 sourceChainId;
uint64 amountSat;
uint32 requestedAt;
}

struct TimeoutRefundRoute {
Expand Down Expand Up @@ -256,6 +257,10 @@ contract L1BTCRedeemerWormhole is
{
allowedSenders[_sender] = _allowed;
emit AllowedSenderUpdated(_sender, _allowed);
if (!_allowed && allowedSenderSourceChainIds[_sender] != 0) {
delete allowedSenderSourceChainIds[_sender];
emit AllowedSenderSourceChainUpdated(_sender, false, 0);
}
if (!_allowed && allowedSenderWormholeChainIds[_sender] != 0) {
delete allowedSenderWormholeChainIds[_sender];
emit AllowedSenderWormholeChainUpdated(_sender, 0);
Expand Down Expand Up @@ -581,12 +586,16 @@ contract L1BTCRedeemerWormhole is
rebateContext
);

IBridgeTypes.RedemptionRequest memory redemption = thresholdBridge
.pendingRedemptions(redemptionKey);

uint256 amountSat = completedTransfer.amount / SATOSHI_MULTIPLIER;
require(amountSat <= type(uint64).max, "Amount too large");
timedOutRedemptionRefunds[redemptionKey] = TimedOutRedemptionRefund({
l2User: payload.l2User,
sourceChainId: completedTransfer.sourceChainId,
amountSat: uint64(amountSat)
amountSat: uint64(amountSat),
requestedAt: redemption.requestedAt
});

emit RedemptionRequested(
Expand Down Expand Up @@ -631,6 +640,10 @@ contract L1BTCRedeemerWormhole is
timedOutRedemption.requestedAmount == refund.amountSat,
"Wrong timeout refund amount"
);
require(
timedOutRedemption.requestedAt == refund.requestedAt,
"Wrong timeout refund request"
);

TimeoutRefundRoute memory route = timeoutRefundRoutes[
refund.sourceChainId
Expand Down
3 changes: 3 additions & 0 deletions solidity/contracts/test/MockL1BTCRedeemerWormhole.sol
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ contract MockL1BTCRedeemerWormhole is
{
allowedSenders[_sender] = _allowed;
emit AllowedSenderUpdated(_sender, _allowed);
if (!_allowed) {
delete allowedSenderSourceChainIds[_sender];
}
if (!_allowed && allowedSenderWormholeChainIds[_sender] != 0) {
delete allowedSenderWormholeChainIds[_sender];
emit AllowedSenderWormholeChainUpdated(_sender, 0);
Expand Down
17 changes: 17 additions & 0 deletions solidity/contracts/test/MockTBTCBridge.sol
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,23 @@ contract MockTBTCBridge is IBridge {
return _timedOutRedemptions[redemptionKey];
}

function setTimedOutRedemption(
uint256 redemptionKey,
address redeemer,
uint64 requestedAmount,
uint32 requestedAt
) external {
_timedOutRedemptions[redemptionKey] = IBridgeTypes.RedemptionRequest({
redeemer: redeemer,
requestedAmount: requestedAmount,
treasuryFee: _redemptionTreasuryFeeDivisor > 0
? requestedAmount / _redemptionTreasuryFeeDivisor
: 0,
txMaxFee: _redemptionTxMaxFee,
requestedAt: requestedAt
});
}

function redemptionParameters()
external
view
Expand Down
21 changes: 21 additions & 0 deletions solidity/contracts/test/TestL1BTCRedeemerWormhole.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity 0.8.17;

import "../cross-chain/wormhole/L1BTCRedeemerWormhole.sol";

contract TestL1BTCRedeemerWormhole is L1BTCRedeemerWormhole {
function seedTimedOutRedemptionRefund(
uint256 redemptionKey,
address l2User,
uint256 sourceChainId,
uint64 amountSat,
uint32 requestedAt
) external {
timedOutRedemptionRefunds[redemptionKey] = TimedOutRedemptionRefund({
l2User: l2User,
sourceChainId: sourceChainId,
amountSat: amountSat,
requestedAt: requestedAt
});
}
}
29 changes: 29 additions & 0 deletions solidity/test/bridge/RebateStaking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2081,5 +2081,34 @@ describe("RebateStaking", () => {
).to.be.equal(rebateCap.sub(rebateBAmount).sub(rebateCAmount))
})
})

context("when the start index was not advanced before transfer", () => {
before(async () => {
await createSnapshot()

await bridge.applyForRebate(thirdParty.address, rebateCap.div(4))
await increaseTime((await rebateStaking.rollingWindow()).toNumber() + 1)

await rebateStaking
.connect(deployer)
.forceStakeTransfer(thirdParty.address, governance.address)
})

after(async () => {
await restoreSnapshot()
})

it("should not copy stale rebates from a lazy rolling window index", async () => {
expect(
await rebateStaking.getRebateLength(governance.address)
).to.be.equal(0)
})

it("should restore the full available rebate for the new staker", async () => {
expect(
await rebateStaking.getAvailableRebate(governance.address)
).to.be.equal(rebateCap)
})
})
})
})
133 changes: 133 additions & 0 deletions solidity/test/cross-chain/wormhole/L1BTCRedeemerWormhole.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
ReimbursementPool,
WormholeBridgeStub,
MockTBTCVault,
TestL1BTCRedeemerWormhole,
TestERC20,
} from "../../../typechain"

chai.use(smock.matchers)
Expand Down Expand Up @@ -1713,6 +1715,101 @@ describe("L1BTCRedeemerWormhole (using Mock)", () => {
})
})

describe("refundTimedOutRedemptionToL2 request identity", () => {
const sourceChainId = 8453
const redemptionKey = BigNumber.from(1234)
const recordedRequestedAt = 1000
const timedOutRequestedAt = 1001

let productionRedeemer: TestL1BTCRedeemerWormhole
let productionBridge: MockTBTCBridge

before(async () => {
await createSnapshot()

const MockBankFactory = await ethers.getContractFactory("MockBank")
const productionBank = (await MockBankFactory.deploy()) as MockBank
await productionBank.deployed()

const MockTBTCVaultFactory = await ethers.getContractFactory(
"contracts/test/MockTBTCVault.sol:MockTBTCVault"
)
const productionVault =
(await MockTBTCVaultFactory.deploy()) as MockTBTCVault
await productionVault.deployed()

const TestERC20Factory = await ethers.getContractFactory("TestERC20")
const wormholeTbtc = (await TestERC20Factory.deploy()) as TestERC20
await wormholeTbtc.deployed()
await productionVault.setTbtcToken(wormholeTbtc.address)

const MockTBTCBridgeFactory = await ethers.getContractFactory(
"MockTBTCBridge"
)
productionBridge =
(await MockTBTCBridgeFactory.deploy()) as MockTBTCBridge
await productionBridge.deployed()

const TestL1BTCRedeemerWormholeFactory = await ethers.getContractFactory(
"TestL1BTCRedeemerWormhole"
)
const implementation = await TestL1BTCRedeemerWormholeFactory.deploy()
await implementation.deployed()
const initializerData =
TestL1BTCRedeemerWormholeFactory.interface.encodeFunctionData(
"initialize",
[
productionBridge.address,
thirdParty.address,
wormholeTbtc.address,
productionBank.address,
productionVault.address,
]
)
const ERC1967ProxyFactory = await ethers.getContractFactory(
"ERC1967Proxy"
)
const proxy = await ERC1967ProxyFactory.deploy(
implementation.address,
initializerData
)
await proxy.deployed()
productionRedeemer = TestL1BTCRedeemerWormholeFactory.attach(
proxy.address
) as TestL1BTCRedeemerWormhole

await productionRedeemer.seedTimedOutRedemptionRefund(
redemptionKey,
thirdParty.address,
sourceChainId,
exampleAmountInSatoshis,
recordedRequestedAt
)
await productionBridge.setTimedOutRedemption(
redemptionKey,
productionRedeemer.address,
exampleAmountInSatoshis,
timedOutRequestedAt
)
})

after(async () => {
await restoreSnapshot()
})

it("should reject a timeout refund from a different redemption request", async () => {
const refundRecord = await productionRedeemer.timedOutRedemptionRefunds(
redemptionKey
)

expect(refundRecord.requestedAt).to.equal(recordedRequestedAt)

await expect(
productionRedeemer.refundTimedOutRedemptionToL2(redemptionKey)
).to.be.revertedWith("Wrong timeout refund request")
})
})

// Regression tests for the audit fix that adds VAA emitter-chain
// authentication. Without these checks, any contract deployed at the
// same address on a foreign chain with a registered Wormhole Token
Expand Down Expand Up @@ -1910,6 +2007,42 @@ describe("L1BTCRedeemerWormhole (using Mock)", () => {
})
})

describe("updateAllowedSender revocation clears source chain ID", () => {
const sender = ethers.utils.hexZeroPad("0xbeef", 32)

before(async () => {
await createSnapshot()
await l1BtcRedeemer
.connect(governance)
.updateAllowedSenderWithSourceChain(sender, true, 8453)
})

after(async () => {
await restoreSnapshot()
})

it("should clear the EVM source chain ID on revocation", async () => {
expect(await l1BtcRedeemer.allowedSenderSourceChainIds(sender)).to.equal(
8453
)

await l1BtcRedeemer.connect(governance).updateAllowedSender(sender, false)

expect(await l1BtcRedeemer.allowedSenderSourceChainIds(sender)).to.equal(
0
)
})

it("should leave the source chain ID cleared after legacy re-enable", async () => {
await l1BtcRedeemer.connect(governance).updateAllowedSender(sender, false)
await l1BtcRedeemer.connect(governance).updateAllowedSender(sender, true)

expect(await l1BtcRedeemer.allowedSenderSourceChainIds(sender)).to.equal(
0
)
})
})

describe("updateAllowedSenderWormholeChain", () => {
const sender = ethers.utils.hexZeroPad("0xaaaa", 32)

Expand Down
Loading