We start with three payments from two synthetic buyers. The first buyer contributes 7 units and later contributes another 5 units while the second contributes 8 units. Our ledger must hold 20 units across two buyer records because the number of payments answers a different question from the number of people receiving an allocation.

The downloadable JavaScript fixture uses integer arithmetic to inspect a Solidity sale design without deploying a contract. Every account label and quantity is invented for this example. The ledger models cumulative deposits and settlement allocation but deliberately leaves token transfers and transaction fees outside its boundary.

Membership needs an explicit representation

A Solidity mapping returns its default value for a missing key. That makes a zero based array index an awkward membership signal because the first buyer has a legitimate index of zero. Testing that index alone to decide whether a buyer already exists can append the first buyer several times.

We use a Map in the fixture so membership remains separate from the amount deposited. In Solidity the same distinction can use an explicit membership mapping alongside the deposit mapping and buyer array. An index plus one encoding also works if every reader preserves its meaning and zero always means absent.

function deposit(buyer, amount) {
  const previous = deposits.get(buyer) ?? 0n;
  if (amount <= 0n || previous + amount > participantLimit)
    throw new Error("participant limit");
  if (total + amount > capacity) throw new Error("capacity");
  if (!deposits.has(buyer)) buyers.push(buyer);
  deposits.set(buyer, previous + amount);
  total += amount;
}

We check capacity before changing either representation. A rejected deposit must leave the buyer list and the total unchanged. The fixture rejects a cumulative buyer limit violation and a global capacity violation independently because a payment can satisfy one limit while breaking the other.

Settlement needs a declared denominator

Our synthetic sale offers 1000 indivisible allocation units against a capacity of 25 payment units. We deliberately use capacity as the denominator. Each deposited unit buys 40 allocation units even when the sale finishes below capacity. The accepted 20 payment units therefore allocate 800 units and leave 200 units outside the buyer allocation.

A proportional sale uses total accepted deposits as its denominator instead. In that policy the same buyers share all 1000 units and a buyer's final quantity changes when another buyer contributes. Both policies have coherent arithmetic but the contract and interface need to agree on which promise they make.

We choose the capacity based policy here because it makes the accounting rule easy to inspect and lets the settlement test distinguish an incomplete sale from a completely allocated token inventory without assuming that every token held by the sale has already been promised to a buyer.

Make the invariants visible

After every accepted deposit the sum of individual balances must equal the recorded total. Each buyer appears once in the settlement list. The cumulative balance for a buyer stays within the participant limit and the aggregate stays within capacity. These statements describe the state independently of the functions that update it.

For a large participant set we avoid making finalization depend on one unbounded transaction. A cursor can distribute bounded batches while recording which records have completed. A pull based claim mechanism is another option. Either approach needs a rule preventing a second payment for the same entitlement and a defined treatment of integer rounding residue.

The fixture has no external call during a deposit and makes no claim about callback safety. A Solidity implementation adds tests using actual ERC20 transfers and reverting recipients. It also specifies whether payment is native currency or a token whose transfer can reduce the received amount.

2 buyers; 20 payment units; 800 allocated; 200 remaining
10 assertions passed

Exercise the boundary before adding the interface

The assertions cover a repeat buyer and a new buyer along with both rejection paths. We also verify the final allocation sum and the residual inventory. That last check catches a mistaken denominator even when every individual transfer looks plausible in isolation.

For a Solidity test suite we carry these invariants into a sequence of deposits from multiple actors. We inspect state after every rejection and after each settlement batch. If the sale accepts changing parameters then tests must define which parameters become immutable once the first contribution arrives.

A partial final distribution batch followed by a retry tests whether the buyer list can support settlement safely. The retry must begin at the next unsettled buyer while retaining the original allocation denominator and the amount already distributed.

Sources and examples