From 35b4a058054be9a301a4e587b2e77e6a58484158 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Fri, 5 Jun 2026 11:28:25 +0200 Subject: [PATCH 01/10] tests: add poc --- .../TicketBrokerDustDepositGriefingPoC.sol | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/test/TicketBrokerDustDepositGriefingPoC.sol diff --git a/src/test/TicketBrokerDustDepositGriefingPoC.sol b/src/test/TicketBrokerDustDepositGriefingPoC.sol new file mode 100644 index 00000000..a0305e99 --- /dev/null +++ b/src/test/TicketBrokerDustDepositGriefingPoC.sol @@ -0,0 +1,197 @@ +pragma solidity ^0.8.9; + +import "ds-test/test.sol"; +import "./base/GovernorBaseTest.sol"; +import "contracts/pm/TicketBroker.sol"; +import "contracts/pm/mixins/MixinTicketBrokerCore.sol"; +import "contracts/pm/mixins/interfaces/MTicketBrokerCore.sol"; +import "contracts/rounds/IRoundsManager.sol"; +import "./interfaces/ICheatCodes.sol"; + +interface IBondingManagerExtended { + function getFirstTranscoderInPool() external view returns (address); + function getNextTranscoderInPool(address _transcoder) external view returns (address); +} + +// forge test --match-contract TicketBrokerDustDepositGriefingPoC --fork-url -vvv +contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { + event WinningTicketTransfer(address indexed sender, address indexed recipient, uint256 amount); + TicketBroker public constant TICKET_BROKER = TicketBroker(0xa8bB618B1520E284046F3dFc448851A1Ff26e41B); + IRoundsManager public constant ROUNDS_MANAGER = IRoundsManager(0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f); + IBondingManagerExtended public constant BONDING_MANAGER = + IBondingManagerExtended(0x35Bcf3c30594191d53231E4FF333E8A770453e40); + + uint256 senderPrivateKey = 0xb0b; + address sender; + address victimTranscoder; + address attacker; + + function setUp() public { + sender = CHEATS.addr(senderPrivateKey); + victimTranscoder = BONDING_MANAGER.getFirstTranscoderInPool(); + attacker = newAddr(); + CHEATS.deal(sender, 2 ether); + CHEATS.deal(attacker, 0.1 ether); + } + + function testDustDepositGriefing() public { + // The sender's deposit is 0 + (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 0); + + // The sender's reserve is 0 + assertEq(TICKET_BROKER.getReserveInfo(sender).fundsRemaining, 0); + + // A valid ticket exists for the victim + (MTicketBrokerCore.Ticket memory ticket, bytes memory sig, uint256 rand) = _createSignedTicket( + victimTranscoder, + sender, + 1 ether + ); + + // The attacker frontruns victim's transaction + CHEATS.prank(attacker); + TICKET_BROKER.fundDepositAndReserveFor{ value: 1 wei }(sender, 1 wei, 0); + + // Sanity-check: The sender now has 1 wei deposit + (info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 1 wei); + + // Consequence 1: The victim now expected to receive 1 wei instead of 1 ETH + CHEATS.expectEmit(true, true, true, true); + emit WinningTicketTransfer(sender, victimTranscoder, 1 wei); + + // Unchanged victim transcoder transaction + CHEATS.prank(victimTranscoder); + TICKET_BROKER.redeemWinningTicket(ticket, sig, rand); + + // Consequence 2: The ticket is marked as used + assertTrue(TICKET_BROKER.usedTickets(_getTicketHash(ticket))); + + // Sanity-check: The deposit is consumed + (info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 0); + } + + function testRaceConditionPartialPayout() public { + address transcoder2 = BONDING_MANAGER.getNextTranscoderInPool(victimTranscoder); + + uint256 faceValue = 1 ether; + + // Sender funds 1.5x face value: enough to fully cover one ticket but not two + CHEATS.prank(sender); + TICKET_BROKER.fundDepositAndReserve{ value: faceValue + faceValue / 2 }(faceValue + faceValue / 2, 0); + + (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, faceValue + faceValue / 2); + + // Two valid winning tickets from the same sender to two different transcoders + (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = + _createSignedTicketWithNonce(victimTranscoder, sender, faceValue, 0); + (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = + _createSignedTicketWithNonce(transcoder2, sender, faceValue, 1); + + // Transcoder 1 redeems first and receives full face value + CHEATS.expectEmit(true, true, true, true); + emit WinningTicketTransfer(sender, victimTranscoder, faceValue); + CHEATS.prank(victimTranscoder); + TICKET_BROKER.redeemWinningTicket(ticket1, sig1, rand1); + + // Deposit is now 0.5 ETH, not enough to cover the second ticket in full + (info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, faceValue / 2); + + // Transcoder 2 redeems second ticket and receives only 0.5 ETH despite a valid 1 ETH ticket + CHEATS.expectEmit(true, true, true, true); + emit WinningTicketTransfer(sender, transcoder2, faceValue / 2); + CHEATS.prank(transcoder2); + TICKET_BROKER.redeemWinningTicket(ticket2, sig2, rand2); + + // Ticket 2 is permanently burned despite the partial payout + assertTrue(TICKET_BROKER.usedTickets(_getTicketHash(ticket2))); + (info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 0); + } + + function _createSignedTicket( + address _recipient, + address _sender, + uint256 _faceValue + ) + internal + returns ( + MTicketBrokerCore.Ticket memory, + bytes memory, + uint256 + ) + { + uint256 recipientRand = 987654321; + uint256 creationRound = ROUNDS_MANAGER.currentRound(); + bytes32 blockHash = ROUNDS_MANAGER.blockHashForRound(creationRound); + + MTicketBrokerCore.Ticket memory ticket = MTicketBrokerCore.Ticket({ + recipient: _recipient, + sender: _sender, + faceValue: _faceValue, + winProb: type(uint256).max, + senderNonce: 0, + recipientRandHash: keccak256(abi.encodePacked(recipientRand)), + auxData: abi.encodePacked(creationRound, blockHash) + }); + + bytes32 ticketHash = _getTicketHash(ticket); + bytes32 ethSignedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", ticketHash)); + (uint8 v, bytes32 r, bytes32 s) = CHEATS.sign(senderPrivateKey, ethSignedHash); + + return (ticket, abi.encodePacked(r, s, v), recipientRand); + } + + function _createSignedTicketWithNonce( + address _recipient, + address _sender, + uint256 _faceValue, + uint256 _nonce + ) + internal + returns ( + MTicketBrokerCore.Ticket memory, + bytes memory, + uint256 + ) + { + uint256 recipientRand = 987654321; + uint256 creationRound = ROUNDS_MANAGER.currentRound(); + bytes32 blockHash = ROUNDS_MANAGER.blockHashForRound(creationRound); + + MTicketBrokerCore.Ticket memory ticket = MTicketBrokerCore.Ticket({ + recipient: _recipient, + sender: _sender, + faceValue: _faceValue, + winProb: type(uint256).max, + senderNonce: _nonce, + recipientRandHash: keccak256(abi.encodePacked(recipientRand)), + auxData: abi.encodePacked(creationRound, blockHash) + }); + + bytes32 ticketHash = _getTicketHash(ticket); + bytes32 ethSignedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", ticketHash)); + (uint8 v, bytes32 r, bytes32 s) = CHEATS.sign(senderPrivateKey, ethSignedHash); + + return (ticket, abi.encodePacked(r, s, v), recipientRand); + } + + function _getTicketHash(MTicketBrokerCore.Ticket memory t) internal pure returns (bytes32) { + return + keccak256( + abi.encodePacked( + t.recipient, + t.sender, + t.faceValue, + t.winProb, + t.senderNonce, + t.recipientRandHash, + t.auxData + ) + ); + } +} From a2a6ad1e41c4a033e5b3b2f320035b3559032a28 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Fri, 5 Jun 2026 11:29:02 +0200 Subject: [PATCH 02/10] fix: ensure sender deposit and reserve cover ticket face value --- contracts/pm/mixins/MixinTicketBrokerCore.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/contracts/pm/mixins/MixinTicketBrokerCore.sol b/contracts/pm/mixins/MixinTicketBrokerCore.sol index d2387483..830c8179 100644 --- a/contracts/pm/mixins/MixinTicketBrokerCore.sol +++ b/contracts/pm/mixins/MixinTicketBrokerCore.sol @@ -125,8 +125,11 @@ abstract contract MixinTicketBrokerCore is MixinContractRegistry, MReserve, MTic // Require sender to be locked require(isLocked(sender), "sender is unlocked"); - // Require either a non-zero deposit or non-zero reserve for the sender - require(sender.deposit > 0 || remainingReserve(_ticket.sender) > 0, "sender deposit and reserve are zero"); + // Require sender deposit and reserve to fully cover the ticket face value + require( + sender.deposit.add(remainingReserve(_ticket.sender)) >= _ticket.faceValue, + "sender deposit and reserve insufficient to cover ticket face value" + ); // Mark ticket as used to prevent replay attacks involving redeeming // the same winning ticket multiple times From e67425ff5c9ed8a10a873baf38acd33e8ecc68ab Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Fri, 5 Jun 2026 11:30:20 +0200 Subject: [PATCH 03/10] tests: add fix --- .../TicketBrokerDustDepositGriefingFix.sol | 111 ++++++++++++++++++ .../TicketBrokerDustDepositGriefingPoC.sol | 17 ++- 2 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 src/test/TicketBrokerDustDepositGriefingFix.sol diff --git a/src/test/TicketBrokerDustDepositGriefingFix.sol b/src/test/TicketBrokerDustDepositGriefingFix.sol new file mode 100644 index 00000000..1c4ba2c5 --- /dev/null +++ b/src/test/TicketBrokerDustDepositGriefingFix.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.9; + +import "./TicketBrokerDustDepositGriefingPoC.sol"; +import { TicketBroker } from "contracts/pm/TicketBroker.sol"; + +// forge test --match-contract TicketBrokerDustDepositGriefingFix --fork-url -vvv +contract TicketBrokerDustDepositGriefingFix is TicketBrokerDustDepositGriefingPoC { + function _upgradeTicketBroker() internal { + TicketBroker newTicketBrokerTarget = new TicketBroker(address(CONTROLLER)); + + stageAndExecuteOne( + address(CONTROLLER), + 0, + abi.encodeWithSelector( + CONTROLLER.setContractInfo.selector, + keccak256(abi.encodePacked("TicketBrokerTarget")), + address(newTicketBrokerTarget), + bytes32(0) + ) + ); + } + + function testFixDustDepositGriefing() public { + _upgradeTicketBroker(); + + (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 0); + assertEq(TICKET_BROKER.getReserveInfo(sender).fundsRemaining, 0); + + (MTicketBrokerCore.Ticket memory ticket, bytes memory sig, uint256 rand) = _createSignedTicket( + victimTranscoder, + sender, + 1 ether + ); + + CHEATS.prank(attacker); + TICKET_BROKER.fundDepositAndReserveFor{ value: 1 wei }(sender, 1 wei, 0); + + (info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 1 wei); + + CHEATS.prank(victimTranscoder); + CHEATS.expectRevert("sender deposit and reserve insufficient to cover ticket face value"); + TICKET_BROKER.redeemWinningTicket(ticket, sig, rand); + + assertFalse(TICKET_BROKER.usedTickets(_getTicketHash(ticket))); + } + + function testFixRaceConditionPartialPayout() public { + _upgradeTicketBroker(); + + address transcoder2 = BONDING_MANAGER.getNextTranscoderInPool(victimTranscoder); + uint256 faceValue = 1 ether; + + CHEATS.prank(sender); + TICKET_BROKER.fundDepositAndReserve{ value: faceValue + faceValue / 2 }(faceValue + faceValue / 2, 0); + + (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = _createSignedTicketWithNonce( + victimTranscoder, + sender, + faceValue, + 0 + ); + (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = _createSignedTicketWithNonce( + transcoder2, + sender, + faceValue, + 1 + ); + + CHEATS.expectEmit(true, true, true, true); + emit WinningTicketTransfer(sender, victimTranscoder, faceValue); + CHEATS.prank(victimTranscoder); + TICKET_BROKER.redeemWinningTicket(ticket1, sig1, rand1); + + (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, faceValue / 2); + + CHEATS.prank(transcoder2); + CHEATS.expectRevert("sender deposit and reserve insufficient to cover ticket face value"); + TICKET_BROKER.redeemWinningTicket(ticket2, sig2, rand2); + + assertFalse(TICKET_BROKER.usedTickets(_getTicketHash(ticket2))); + } + + function testNormalRedemption() public { + _upgradeTicketBroker(); + + uint256 faceValue = 1 ether; + + CHEATS.prank(sender); + TICKET_BROKER.fundDepositAndReserve{ value: faceValue }(faceValue, 0); + + (MTicketBrokerCore.Ticket memory ticket, bytes memory sig, uint256 rand) = _createSignedTicket( + victimTranscoder, + sender, + faceValue + ); + + CHEATS.expectEmit(true, true, true, true); + emit WinningTicketTransfer(sender, victimTranscoder, faceValue); + CHEATS.prank(victimTranscoder); + TICKET_BROKER.redeemWinningTicket(ticket, sig, rand); + + assertTrue(TICKET_BROKER.usedTickets(_getTicketHash(ticket))); + + (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); + assertEq(info.deposit, 0); + } +} diff --git a/src/test/TicketBrokerDustDepositGriefingPoC.sol b/src/test/TicketBrokerDustDepositGriefingPoC.sol index a0305e99..81fd752c 100644 --- a/src/test/TicketBrokerDustDepositGriefingPoC.sol +++ b/src/test/TicketBrokerDustDepositGriefingPoC.sol @@ -10,6 +10,7 @@ import "./interfaces/ICheatCodes.sol"; interface IBondingManagerExtended { function getFirstTranscoderInPool() external view returns (address); + function getNextTranscoderInPool(address _transcoder) external view returns (address); } @@ -86,10 +87,18 @@ contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { assertEq(info.deposit, faceValue + faceValue / 2); // Two valid winning tickets from the same sender to two different transcoders - (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = - _createSignedTicketWithNonce(victimTranscoder, sender, faceValue, 0); - (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = - _createSignedTicketWithNonce(transcoder2, sender, faceValue, 1); + (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = _createSignedTicketWithNonce( + victimTranscoder, + sender, + faceValue, + 0 + ); + (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = _createSignedTicketWithNonce( + transcoder2, + sender, + faceValue, + 1 + ); // Transcoder 1 redeems first and receives full face value CHEATS.expectEmit(true, true, true, true); From f5cebc1029217a015fbb0b6497002be45f76e69f Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Fri, 5 Jun 2026 11:31:12 +0200 Subject: [PATCH 04/10] tests: add/update unit tests --- test/unit/TicketBroker.js | 88 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 4 deletions(-) diff --git a/test/unit/TicketBroker.js b/test/unit/TicketBroker.js index 3d122bcc..4d979ff2 100644 --- a/test/unit/TicketBroker.js +++ b/test/unit/TicketBroker.js @@ -972,12 +972,92 @@ describe("TicketBroker", () => { it("reverts if sender's deposit and reserve are zero", async () => { const recipientRand = 5 - const ticket = createWinningTicket(recipient, sender, recipientRand) + const ticket = createWinningTicket( + recipient, + sender, + recipientRand, + 1 + ) const senderSig = await signMsg(getTicketHash(ticket), sender) await expect( broker.redeemWinningTicket(ticket, senderSig, recipientRand) - ).to.be.revertedWith("sender deposit and reserve are zero") + ).to.be.revertedWith( + "sender deposit and reserve insufficient to cover ticket face value" + ) + }) + + it("reverts if faceValue exceeds deposit and reserve combined", async () => { + const deposit = 500 + const reserve = 300 + await broker.fundDeposit({value: deposit}) + await broker.fundReserve({value: reserve}) + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + + const recipientRand = 5 + const faceValue = deposit + reserve + 1 + const ticket = createWinningTicket( + recipient, + sender, + recipientRand, + faceValue + ) + const senderSig = await signMsg(getTicketHash(ticket), sender) + + await expect( + broker + .connect(signers[1]) + .redeemWinningTicket(ticket, senderSig, recipientRand) + ).to.be.revertedWith( + "sender deposit and reserve insufficient to cover ticket face value" + ) + }) + + it("succeeds when faceValue equals deposit and reserve combined", async () => { + const deposit = 500 + const reserve = 300 + await broker.fundDeposit({value: deposit}) + await broker.fundReserve({value: reserve}) + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + await fixture.bondingManager.setMockUint256( + functionSig("getTranscoderPoolSize()"), + 1 + ) + await fixture.bondingManager.setMockBool( + functionSig("isActiveTranscoder(address)"), + true + ) + + const recipientRand = 5 + const faceValue = deposit + reserve + const ticket = createWinningTicket( + recipient, + sender, + recipientRand, + faceValue + ) + const senderSig = await signMsg(getTicketHash(ticket), sender) + + await broker + .connect(signers[1]) + .redeemWinningTicket(ticket, senderSig, recipientRand) + + const endDeposit = ( + await broker.getSenderInfo(sender) + ).sender.deposit.toString() + assert.equal(endDeposit, "0") + assert.equal( + ( + await broker.getSenderInfo(sender) + ).reserve.fundsRemaining.toString(), + "0" + ) }) describe("only legacy long-signatures are supported by the protocol ", () => { @@ -1554,7 +1634,7 @@ describe("TicketBroker", () => { recipient2.address, sender, recipientRand, - faceValue + 15 + allocation ) const senderSig2 = await signMsg( getTicketHash(ticket2), @@ -1627,7 +1707,7 @@ describe("TicketBroker", () => { await broker.fundDeposit({value: deposit}) const recipientRand = 5 - const faceValue = 1000 + const faceValue = deposit const ticket = createWinningTicket( recipient, sender, From 1f5b9924925aca5eff7938ffda6665a1eb588fe1 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Fri, 5 Jun 2026 14:15:36 +0200 Subject: [PATCH 05/10] docs: update nat specs --- contracts/pm/mixins/MixinTicketBrokerCore.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contracts/pm/mixins/MixinTicketBrokerCore.sol b/contracts/pm/mixins/MixinTicketBrokerCore.sol index 830c8179..812a8b35 100644 --- a/contracts/pm/mixins/MixinTicketBrokerCore.sol +++ b/contracts/pm/mixins/MixinTicketBrokerCore.sol @@ -106,7 +106,9 @@ abstract contract MixinTicketBrokerCore is MixinContractRegistry, MReserve, MTic /** * @notice Redeems a winning ticket that has been signed by a sender and reveals the - recipient recipientRand that corresponds to the recipientRandHash included in the ticket + recipient recipientRand that corresponds to the recipientRandHash included in the ticket. + * The sender's deposit and reserve combined must fully cover the ticket face value, + * otherwise the call reverts and the ticket is not consumed. * @param _ticket Winning ticket to be redeemed in order to claim payment * @param _sig Sender's signature over the hash of `_ticket` * @param _recipientRand The preimage for the recipientRandHash included in `_ticket` From 9575fdc5aa36f33c198a4621b246342503e3b3a1 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Fri, 5 Jun 2026 14:16:54 +0200 Subject: [PATCH 06/10] enh: poc tests --- .../TicketBrokerDustDepositGriefingFix.sol | 28 +++--- .../TicketBrokerDustDepositGriefingPoC.sol | 87 ++++--------------- 2 files changed, 31 insertions(+), 84 deletions(-) diff --git a/src/test/TicketBrokerDustDepositGriefingFix.sol b/src/test/TicketBrokerDustDepositGriefingFix.sol index 1c4ba2c5..c49cc906 100644 --- a/src/test/TicketBrokerDustDepositGriefingFix.sol +++ b/src/test/TicketBrokerDustDepositGriefingFix.sol @@ -44,44 +44,42 @@ contract TicketBrokerDustDepositGriefingFix is TicketBrokerDustDepositGriefingPo CHEATS.expectRevert("sender deposit and reserve insufficient to cover ticket face value"); TICKET_BROKER.redeemWinningTicket(ticket, sig, rand); - assertFalse(TICKET_BROKER.usedTickets(_getTicketHash(ticket))); + assertFalse(TICKET_BROKER.usedTickets(TICKET_BROKER.getTicketHash(ticket))); } function testFixRaceConditionPartialPayout() public { _upgradeTicketBroker(); - address transcoder2 = BONDING_MANAGER.getNextTranscoderInPool(victimTranscoder); + address anotherTranscoder = BONDING_MANAGER.getNextTranscoderInPool(victimTranscoder); uint256 faceValue = 1 ether; CHEATS.prank(sender); TICKET_BROKER.fundDepositAndReserve{ value: faceValue + faceValue / 2 }(faceValue + faceValue / 2, 0); - (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = _createSignedTicketWithNonce( - victimTranscoder, + (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = _createSignedTicket( + anotherTranscoder, sender, - faceValue, - 0 + faceValue ); - (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = _createSignedTicketWithNonce( - transcoder2, + (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = _createSignedTicket( + victimTranscoder, sender, - faceValue, - 1 + faceValue ); CHEATS.expectEmit(true, true, true, true); - emit WinningTicketTransfer(sender, victimTranscoder, faceValue); - CHEATS.prank(victimTranscoder); + emit WinningTicketTransfer(sender, anotherTranscoder, faceValue); + CHEATS.prank(anotherTranscoder); TICKET_BROKER.redeemWinningTicket(ticket1, sig1, rand1); (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); assertEq(info.deposit, faceValue / 2); - CHEATS.prank(transcoder2); + CHEATS.prank(victimTranscoder); CHEATS.expectRevert("sender deposit and reserve insufficient to cover ticket face value"); TICKET_BROKER.redeemWinningTicket(ticket2, sig2, rand2); - assertFalse(TICKET_BROKER.usedTickets(_getTicketHash(ticket2))); + assertFalse(TICKET_BROKER.usedTickets(TICKET_BROKER.getTicketHash(ticket2))); } function testNormalRedemption() public { @@ -103,7 +101,7 @@ contract TicketBrokerDustDepositGriefingFix is TicketBrokerDustDepositGriefingPo CHEATS.prank(victimTranscoder); TICKET_BROKER.redeemWinningTicket(ticket, sig, rand); - assertTrue(TICKET_BROKER.usedTickets(_getTicketHash(ticket))); + assertTrue(TICKET_BROKER.usedTickets(TICKET_BROKER.getTicketHash(ticket))); (MixinTicketBrokerCore.Sender memory info, ) = TICKET_BROKER.getSenderInfo(sender); assertEq(info.deposit, 0); diff --git a/src/test/TicketBrokerDustDepositGriefingPoC.sol b/src/test/TicketBrokerDustDepositGriefingPoC.sol index 81fd752c..702cad47 100644 --- a/src/test/TicketBrokerDustDepositGriefingPoC.sol +++ b/src/test/TicketBrokerDustDepositGriefingPoC.sol @@ -67,7 +67,7 @@ contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { TICKET_BROKER.redeemWinningTicket(ticket, sig, rand); // Consequence 2: The ticket is marked as used - assertTrue(TICKET_BROKER.usedTickets(_getTicketHash(ticket))); + assertTrue(TICKET_BROKER.usedTickets(TICKET_BROKER.getTicketHash(ticket))); // Sanity-check: The deposit is consumed (info, ) = TICKET_BROKER.getSenderInfo(sender); @@ -75,7 +75,7 @@ contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { } function testRaceConditionPartialPayout() public { - address transcoder2 = BONDING_MANAGER.getNextTranscoderInPool(victimTranscoder); + address anotherTranscoder = BONDING_MANAGER.getNextTranscoderInPool(victimTranscoder); uint256 faceValue = 1 ether; @@ -87,37 +87,35 @@ contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { assertEq(info.deposit, faceValue + faceValue / 2); // Two valid winning tickets from the same sender to two different transcoders - (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = _createSignedTicketWithNonce( - victimTranscoder, + (MTicketBrokerCore.Ticket memory ticket1, bytes memory sig1, uint256 rand1) = _createSignedTicket( + anotherTranscoder, sender, - faceValue, - 0 + faceValue ); - (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = _createSignedTicketWithNonce( - transcoder2, + (MTicketBrokerCore.Ticket memory ticket2, bytes memory sig2, uint256 rand2) = _createSignedTicket( + victimTranscoder, sender, - faceValue, - 1 + faceValue ); - // Transcoder 1 redeems first and receives full face value + // Another transcoder redeems first and receives full face value CHEATS.expectEmit(true, true, true, true); - emit WinningTicketTransfer(sender, victimTranscoder, faceValue); - CHEATS.prank(victimTranscoder); + emit WinningTicketTransfer(sender, anotherTranscoder, faceValue); + CHEATS.prank(anotherTranscoder); TICKET_BROKER.redeemWinningTicket(ticket1, sig1, rand1); - // Deposit is now 0.5 ETH, not enough to cover the second ticket in full + // Deposit is now 0.5 ETH, not enough to cover the victim's ticket in full (info, ) = TICKET_BROKER.getSenderInfo(sender); assertEq(info.deposit, faceValue / 2); - // Transcoder 2 redeems second ticket and receives only 0.5 ETH despite a valid 1 ETH ticket + // Victim transcoder redeems second and receives only 0.5 ETH despite a valid 1 ETH ticket CHEATS.expectEmit(true, true, true, true); - emit WinningTicketTransfer(sender, transcoder2, faceValue / 2); - CHEATS.prank(transcoder2); + emit WinningTicketTransfer(sender, victimTranscoder, faceValue / 2); + CHEATS.prank(victimTranscoder); TICKET_BROKER.redeemWinningTicket(ticket2, sig2, rand2); - // Ticket 2 is permanently burned despite the partial payout - assertTrue(TICKET_BROKER.usedTickets(_getTicketHash(ticket2))); + // Ticket is permanently burned despite the partial payout + assertTrue(TICKET_BROKER.usedTickets(TICKET_BROKER.getTicketHash(ticket2))); (info, ) = TICKET_BROKER.getSenderInfo(sender); assertEq(info.deposit, 0); } @@ -148,59 +146,10 @@ contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { auxData: abi.encodePacked(creationRound, blockHash) }); - bytes32 ticketHash = _getTicketHash(ticket); + bytes32 ticketHash = TICKET_BROKER.getTicketHash(ticket); bytes32 ethSignedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", ticketHash)); (uint8 v, bytes32 r, bytes32 s) = CHEATS.sign(senderPrivateKey, ethSignedHash); return (ticket, abi.encodePacked(r, s, v), recipientRand); } - - function _createSignedTicketWithNonce( - address _recipient, - address _sender, - uint256 _faceValue, - uint256 _nonce - ) - internal - returns ( - MTicketBrokerCore.Ticket memory, - bytes memory, - uint256 - ) - { - uint256 recipientRand = 987654321; - uint256 creationRound = ROUNDS_MANAGER.currentRound(); - bytes32 blockHash = ROUNDS_MANAGER.blockHashForRound(creationRound); - - MTicketBrokerCore.Ticket memory ticket = MTicketBrokerCore.Ticket({ - recipient: _recipient, - sender: _sender, - faceValue: _faceValue, - winProb: type(uint256).max, - senderNonce: _nonce, - recipientRandHash: keccak256(abi.encodePacked(recipientRand)), - auxData: abi.encodePacked(creationRound, blockHash) - }); - - bytes32 ticketHash = _getTicketHash(ticket); - bytes32 ethSignedHash = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", ticketHash)); - (uint8 v, bytes32 r, bytes32 s) = CHEATS.sign(senderPrivateKey, ethSignedHash); - - return (ticket, abi.encodePacked(r, s, v), recipientRand); - } - - function _getTicketHash(MTicketBrokerCore.Ticket memory t) internal pure returns (bytes32) { - return - keccak256( - abi.encodePacked( - t.recipient, - t.sender, - t.faceValue, - t.winProb, - t.senderNonce, - t.recipientRandHash, - t.auxData - ) - ); - } } From 5a2dd1855810430d3ef9c0487cce3fe01cde2034 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Mon, 8 Jun 2026 11:41:45 +0200 Subject: [PATCH 07/10] fix: correct type for contract info in TicketBrokerDustDepositGriefingFix --- src/test/TicketBrokerDustDepositGriefingFix.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/TicketBrokerDustDepositGriefingFix.sol b/src/test/TicketBrokerDustDepositGriefingFix.sol index c49cc906..17d9c544 100644 --- a/src/test/TicketBrokerDustDepositGriefingFix.sol +++ b/src/test/TicketBrokerDustDepositGriefingFix.sol @@ -16,7 +16,7 @@ contract TicketBrokerDustDepositGriefingFix is TicketBrokerDustDepositGriefingPo CONTROLLER.setContractInfo.selector, keccak256(abi.encodePacked("TicketBrokerTarget")), address(newTicketBrokerTarget), - bytes32(0) + bytes20(0) ) ); } From 1a7326831b8ae10cb2eb00bcfa8a67df3f22bea5 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Tue, 9 Jun 2026 18:19:51 +0200 Subject: [PATCH 08/10] fix: add missing * to natspecs --- contracts/pm/mixins/MixinTicketBrokerCore.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/pm/mixins/MixinTicketBrokerCore.sol b/contracts/pm/mixins/MixinTicketBrokerCore.sol index 812a8b35..706b2aa8 100644 --- a/contracts/pm/mixins/MixinTicketBrokerCore.sol +++ b/contracts/pm/mixins/MixinTicketBrokerCore.sol @@ -106,7 +106,7 @@ abstract contract MixinTicketBrokerCore is MixinContractRegistry, MReserve, MTic /** * @notice Redeems a winning ticket that has been signed by a sender and reveals the - recipient recipientRand that corresponds to the recipientRandHash included in the ticket. + * recipient recipientRand that corresponds to the recipientRandHash included in the ticket. * The sender's deposit and reserve combined must fully cover the ticket face value, * otherwise the call reverts and the ticket is not consumed. * @param _ticket Winning ticket to be redeemed in order to claim payment From 7d8f7318598dfa523ddf5c9a9ec8d80aea428d20 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Tue, 9 Jun 2026 18:35:57 +0200 Subject: [PATCH 09/10] fix: extend TicketBroker unit tests --- test/unit/TicketBroker.js | 79 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/test/unit/TicketBroker.js b/test/unit/TicketBroker.js index 4d979ff2..30b51115 100644 --- a/test/unit/TicketBroker.js +++ b/test/unit/TicketBroker.js @@ -987,7 +987,7 @@ describe("TicketBroker", () => { ) }) - it("reverts if faceValue exceeds deposit and reserve combined", async () => { + it("reverts if faceValue exceeds deposit and reserve combined, but succeeds after topping up deposit", async () => { const deposit = 500 const reserve = 300 await broker.fundDeposit({value: deposit}) @@ -996,6 +996,67 @@ describe("TicketBroker", () => { functionSig("currentRound()"), currentRound ) + await fixture.bondingManager.setMockUint256( + functionSig("getTranscoderPoolSize()"), + 1 + ) + await fixture.bondingManager.setMockBool( + functionSig("isActiveTranscoder(address)"), + true + ) + + const recipientRand = 5 + const faceValue = deposit + reserve + 1 + const ticket = createWinningTicket( + recipient, + sender, + recipientRand, + faceValue + ) + const senderSig = await signMsg(getTicketHash(ticket), sender) + + await expect( + broker + .connect(signers[1]) + .redeemWinningTicket(ticket, senderSig, recipientRand) + ).to.be.revertedWith( + "sender deposit and reserve insufficient to cover ticket face value" + ) + + await broker.fundDeposit({value: 1}) + await broker + .connect(signers[1]) + .redeemWinningTicket(ticket, senderSig, recipientRand) + + const endDeposit = ( + await broker.getSenderInfo(sender) + ).sender.deposit.toString() + assert.equal(endDeposit, "0") + assert.equal( + ( + await broker.getSenderInfo(sender) + ).reserve.fundsRemaining.toString(), + "0" + ) + }) + + it("reverts if faceValue exceeds deposit and reserve combined, but succeeds after topping up reserve", async () => { + const deposit = 500 + const reserve = 300 + await broker.fundDeposit({value: deposit}) + await broker.fundReserve({value: reserve}) + await fixture.roundsManager.setMockUint256( + functionSig("currentRound()"), + currentRound + ) + await fixture.bondingManager.setMockUint256( + functionSig("getTranscoderPoolSize()"), + 1 + ) + await fixture.bondingManager.setMockBool( + functionSig("isActiveTranscoder(address)"), + true + ) const recipientRand = 5 const faceValue = deposit + reserve + 1 @@ -1014,6 +1075,22 @@ describe("TicketBroker", () => { ).to.be.revertedWith( "sender deposit and reserve insufficient to cover ticket face value" ) + + await broker.fundReserve({value: 1}) + await broker + .connect(signers[1]) + .redeemWinningTicket(ticket, senderSig, recipientRand) + + const endDeposit = ( + await broker.getSenderInfo(sender) + ).sender.deposit.toString() + assert.equal(endDeposit, "0") + assert.equal( + ( + await broker.getSenderInfo(sender) + ).reserve.fundsRemaining.toString(), + "0" + ) }) it("succeeds when faceValue equals deposit and reserve combined", async () => { From 60243864485c37a6116d271f15f5e4d068a5c1a0 Mon Sep 17 00:00:00 2001 From: SidestreamSweatyPumpkin Date: Tue, 9 Jun 2026 18:36:24 +0200 Subject: [PATCH 10/10] fix: refactor TicketBrokerDustDepositGriefingPoC contract to use dynamic contract addresses --- .../TicketBrokerDustDepositGriefingPoC.sol | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/test/TicketBrokerDustDepositGriefingPoC.sol b/src/test/TicketBrokerDustDepositGriefingPoC.sol index 702cad47..a8eb3e3f 100644 --- a/src/test/TicketBrokerDustDepositGriefingPoC.sol +++ b/src/test/TicketBrokerDustDepositGriefingPoC.sol @@ -1,26 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.9; -import "ds-test/test.sol"; import "./base/GovernorBaseTest.sol"; import "contracts/pm/TicketBroker.sol"; import "contracts/pm/mixins/MixinTicketBrokerCore.sol"; import "contracts/pm/mixins/interfaces/MTicketBrokerCore.sol"; -import "contracts/rounds/IRoundsManager.sol"; -import "./interfaces/ICheatCodes.sol"; - -interface IBondingManagerExtended { - function getFirstTranscoderInPool() external view returns (address); - - function getNextTranscoderInPool(address _transcoder) external view returns (address); -} +import "contracts/rounds/RoundsManager.sol"; +import "contracts/bonding/BondingManager.sol"; // forge test --match-contract TicketBrokerDustDepositGriefingPoC --fork-url -vvv contract TicketBrokerDustDepositGriefingPoC is GovernorBaseTest { + TicketBroker public immutable TICKET_BROKER; + RoundsManager public immutable ROUNDS_MANAGER; + BondingManager public immutable BONDING_MANAGER; + event WinningTicketTransfer(address indexed sender, address indexed recipient, uint256 amount); - TicketBroker public constant TICKET_BROKER = TicketBroker(0xa8bB618B1520E284046F3dFc448851A1Ff26e41B); - IRoundsManager public constant ROUNDS_MANAGER = IRoundsManager(0xdd6f56DcC28D3F5f27084381fE8Df634985cc39f); - IBondingManagerExtended public constant BONDING_MANAGER = - IBondingManagerExtended(0x35Bcf3c30594191d53231E4FF333E8A770453e40); + + constructor() { + TICKET_BROKER = TicketBroker(getContract("TicketBroker")); + ROUNDS_MANAGER = RoundsManager(getContract("RoundsManager")); + BONDING_MANAGER = BondingManager(getContract("BondingManager")); + } uint256 senderPrivateKey = 0xb0b; address sender;