July 5, 2026

One note, two spend keys: when a double-spend guard forks

A double-spend guard is only as good as the key it checks. Track a note in a spent set and every code path that consumes that note has to agree, byte for byte, on the key that represents it. When two paths disagree, the set silently forks into two sets, and each guards half the doors.

Here is a pool that lets a holder consume a note two ways. withdraw pays the note out. transfer rolls it into a fresh note. Both derive a per-note spend key and refuse to reuse it.

contract Ledger {
    uint256 constant DOMAIN_WITHDRAW = 1;
    uint256 constant DOMAIN_TRANSFER = 2;

    mapping(bytes32 => bool) public spent;
    mapping(uint256 => uint256) public noteValue;

    error AlreadySpent();

    function _spendKey(uint256 domain, uint256 secret, uint256 leafIndex)
        internal pure returns (bytes32)
    {
        return keccak256(abi.encode(domain, secret, leafIndex));
    }

    function withdraw(uint256 secret, uint256 leafIndex, uint256 noteId)
        external returns (uint256)
    {
        bytes32 key = _spendKey(DOMAIN_WITHDRAW, secret, leafIndex);
        if (spent[key]) revert AlreadySpent();
        spent[key] = true;
        return noteValue[noteId];
    }

    function transfer(uint256 secret, uint256 leafIndex, uint256 noteId)
        external returns (uint256)
    {
        bytes32 key = _spendKey(DOMAIN_TRANSFER, secret, leafIndex);
        if (spent[key]) revert AlreadySpent();
        spent[key] = true;
        return noteValue[noteId];
    }
}

The two paths pass the same (secret, leafIndex) into _spendKey, but under different domain tags: 1 for withdraw, 2 for transfer. So the keys differ.

Walk one note through it. Register note 7 worth 100. Call withdraw(0xABCD, 3, 7). It computes keccak256(abi.encode(1, 0xABCD, 3)), finds it unset, marks it, and pays 100. Now call transfer(0xABCD, 3, 7). It computes keccak256(abi.encode(2, 0xABCD, 3)). That key was never touched. The check passes, and the same note pays out 100 a second time. One balance of 100, spent for 200.

The class: divergent domain separation in a uniqueness guard

The bug is not the domain tag. Domain separation is the right instinct, the same discipline behind EIP-712. The bug is scope. A domain tag distinguishes what a value means, so mixing one into a signature stops a withdraw signature from being replayed as a transfer signature. But the spent set does not care what the value means. It answers one question: has this note been consumed. That question has a single true answer per note, and it must resolve to a single key.

Any time a check keys on a derived value and two paths derive it under different constants, the guarded set splits. Each path defends its own half and ignores the other. The general shape: unique[f(x)] on one path, unique[g(x)] on another, where f and g diverge for the same x.

Why it survives the test suite

Because the per-path tests are all green. Call withdraw twice with the same inputs and the second reverts with AlreadySpent. Call transfer twice, same result. The reject-double-spend test passes on every surface in isolation. What no single-surface test exercises is the crossing: spend on one surface, then the other.

The invariant the suite encodes is "a note cannot be spent twice by the same function." The invariant the pool needs is "a note cannot be spent twice." The gap between those two sentences is the whole vulnerability, and it is invisible until a test spends one note through two doors.

The fix

Make every spend path derive the key under one shared domain, so the spent set stays a single set.

uint256 constant DOMAIN_SPEND = 1;

function _spendKey(uint256 secret, uint256 leafIndex)
    internal pure returns (bytes32)
{
    return keccak256(abi.encode(DOMAIN_SPEND, secret, leafIndex));
}

Now withdraw and transfer compute the same key for the same note, and the first spend blocks the second everywhere. If separate tags are genuinely required elsewhere, then register both keys on both paths, so consuming a note closes every door at once. One note, one identity in the set that guards it.

The test to add writes itself: spend a note on path A, then assert path B reverts.

Daniel Kuppitz · Founder & Principal · Solidity audits at Cimora