We give one synthetic vesting schedule 17 units over six seconds. Integer division produces a rate of two units per second and leaves five units outside that rate. A test that stops halfway through the schedule never asks where those remaining units go.

Our independent fixture advances an explicit clock and calculates entitlement directly from each schedule. The JavaScript code uses BigInt so rounding remains visible. It is an accounting reference for Solidity tests and does not model ERC20 callbacks or transaction execution.

Define entitlement before optimizing its calculation

For a schedule starting at time zero the elapsed time is clamped between zero and six seconds. Before completion we calculate the fraction of the original amount earned so far. At completion the full amount becomes earned. Claims subtract the amount already paid so calling twice at the same time returns zero on the second call.

That final entitlement rule prevents the rate division residue from remaining locked forever. It also makes a late claim equivalent to claiming at completion when no other event changes the schedule. The important state is earned quantity minus claimed quantity and neither side should become negative.

function earned(schedule, now) {
  if (now <= schedule.start) return 0n;
  const elapsed = now - schedule.start;
  if (elapsed >= schedule.duration) return schedule.amount;
  return (schedule.amount * elapsed) / schedule.duration;
}
function claim(schedules, now) {
  return schedules.reduce((paid, s) => {
    const due = earned(s, now) - s.claimed;
    s.claimed += due;
    return paid + due;
  }, 0n);
}

We add another schedule for 11 units that starts two seconds later and lasts four seconds. At time three the first schedule has earned eight units while the second has earned two. The participant has earned ten units across both schedules even though neither has finished.

Overlapping schedules change the rate at different times

A Solidity implementation may optimize repeated claims by tracking an emission rate and a queue of future rate changes. Adding a schedule increases the active rate and records when that increase expires. Advancing the accounting clock must apply each expired change in timestamp order.

For each interval the amount earned uses the rate that applied during that interval. Subtracting the timestamps in the wrong order makes unsigned arithmetic revert in Solidity 0.8. Processing an expiry with the rate that applies after the expiry produces a different accounting error that arithmetic checks do not detect.

We want the optimized accumulator to agree with a direct schedule calculation when a claim crosses several expiry points because a participant who returns after a long absence should receive the same entitlement as a participant who called at every boundary even though the two executions visit different intermediate states.

The fixture supplies the direct calculation as an oracle for that comparison. It loops over a tiny fixed collection of schedules. That makes it useful in a test but does not justify an unbounded schedule loop inside a contract transaction.

Partition the clock and compare the totals

We claim at time three and then claim again at a time well beyond both schedules. The first payment is ten units and the final payment is eighteen. Total payments equal the original 28 units and a second late claim pays zero.

We compare that partitioned execution with a fresh copy that claims only once after completion. Both produce the same aggregate. This is a stronger property than checking one expected intermediate balance because it tests that the bookkeeping does not depend on the caller's chosen frequency.

10 units at time 3; 18 units later; 28 units total
9 assertions passed

A Hardhat test can express the same experiment by advancing the local chain clock and mining a block before sending the claim transaction. The contract reads block timestamps so changing a host clock is irrelevant. Each branch should start from a known snapshot so a previous test does not supply an accidental time offset.

Rounding belongs in the conservation rule

The original schedule amount should equal claimed quantity plus remaining entitlement when the schedule is fully funded and no early exit policy changes it. An early exit introduces another destination for the unpaid amount and needs a separate equation. Calling an early exit a normal claim hides that policy difference.

Boundary cases include a zero duration rejected before division and a start time beyond the current clock. We also test an amount smaller than the duration because a rate based implementation then has a zero integer rate even though the schedule promises a positive total.

Generated schedules with different start times can turn the direct entitlement function into a reference oracle. When an optimized claim differs the test should retain the specific timestamps and amounts so the mismatch becomes a small reproducible regression.

Sources and examples