Recording Holding Periods Without a Hard Fork: A Design Review of SecurityToken.sol
Should someone who held a token all month receive the same income as someone who bought it just before settlement? In the previous article, we approached this question through ownership history and time-weighted rights. Recording only the points where balances change lets us reconstruct how much an address held and for how long.
After writing that article, we reopened the code from 2023. The implementation had existed before the formula was
written down. SecurityToken.sol in nigo-protocol-v0 is a prototype that uses balance history to calculate income
for a specified block range. We could express the idea in a smart contract without a hard fork of the underlying
chain. A small calculation makes both its possibilities and its unfinished work easier to see.
This is a retrospective on the May 2023 prototype,
SecurityToken.sol at commit af4cb29.
We found no dedicated tests or migration for this contract, public deployment address, or security-audit evidence.
The implemented label refers to historical source code, not production verification or a current nigo-protocol feature.
The contract's name also says nothing by itself about legal classification as a security.
An Example: 70 for A, 30 for B
Suppose the total supply stays at 100 tokens throughout an income period from block 15 through block 24. We deposit 100 units of a separate payout asset, where one unit is that asset's smallest integer denomination. Token quantities and payout units are different units of measurement. Including both endpoints gives ten blocks, so there are ten payout units to distribute per block.
A holds all 100 tokens from block 10, then transfers 60 to B at block 20. There is no minting or burning during the period, and claims occur after it has ended.
Each balance and income cell lists A first, then B.
| Segment (blocks) | Tokens: A · B | Income: A · B |
|---|---|---|
| 15–19 (5) | 100 · 0 | 50 · 0 |
| 20–24 (5) | 40 · 60 | 20 · 30 |
| Total (10) | — | 70 · 30 |
A receives all 50 units from the first five blocks. The next 50 are split 40:60 between A and B. Total payouts equal the 100-unit deposit. Using only the final balance would allocate 40 to A and 60 to B; this calculation also recognizes A's earlier holding period.
We do not need a copy of the balance at every block to obtain that result. We need the two points where A's balance changed.
Record Changes, Not a Copy at Every Block
A checkpoint records the block at which a balance changed and its new value. In this example, A has (10, 100) and
(20, 40), while B has (20, 60). A balance before its first record is zero. We can reconstruct A as holding 100
tokens at blocks 10–19 and 40 from block 20 until the next change.
The original external mint and transfer paths update a balance before recording its block height and new value.
The internal _burn path calls the same recording function, but no external function exposes it. The state model can
be expressed as this pseudocode:
onBalanceChanged(holder, newBalance):
balances[holder] = newBalance
balanceAt[holder][currentBlock] = newBalance
changedBlocks[holder].append(currentBlock)
This illustrates storage; it is not a complete implementation of same-block consolidation, permissions, or safe payouts. Storage grows with an address's balance changes rather than the chain's total block count. The original also appends repeated changes within a single block to the array.
A checkpoint contains the balance after a transaction. If several transfers occur in one block, the system must decide whether rights use the final balance of that block or distinguish transaction order. A block number is also a ledger position, not wall-clock time. Mapping daily or monthly income to a block range requires a separate policy.
Count Where the Holding Segment Overlaps the Income Period
An Income record contains a start block, end block, amount per block, and reference total supply. Let be the
deposit and the inclusive income-period endpoints. The amount per block, , is:
The floor brackets mean that integer division rounds down. Here, . For each constant-balance segment, use balance , overlapping block count , and fixed reference supply :
Substituting A's two segments gives the same 70:
The original uses inclusive intervals such as [15,24]. This represents the same blocks as the previous article's
half-open [15,25). Either notation works if the entire calculation consistently applies its endpoint rule.
Division order is part of the behavior, too. If the deposit were 103 smallest units, the first division would still allocate ten per block, leaving three units. Per-holder rounding can leave additional dust. Dividing only once at the end would change the original algorithm, so the destination or rollover policy for remainders must be explicit.
Distribution follows a pull model: holders later claim their own income, so the payer need not enumerate everyone in
one transaction. The companion SecurityTokenPublisher.sol handles approvals and initial subscription allocation;
the holding-period calculation lives in these checkpoints and claims. Time-weighted voting and a contribution ledger
were not implemented by this code.
What the Code Does, and What the Next Implementation Must Guarantee
Being able to calculate 70 does not complete a settlement protocol. Money must be paid only once, and a failed transfer must not remain recorded as a successful settlement. The original state transitions make the missing guarantees concrete.
| Observed behavior in the original | Settlement implication | Decision for the next implementation |
|---|---|---|
Starts at the saved offset, then stores incomes.length - 1 | Confusing the last processed position with the next unprocessed one can revisit a completed campaign | Define nextClaimIndex and per-campaign payment state |
| Overwrites a same-block balance but always appends the block number | Can create an interval whose end precedes its start and revert a claim during integer subtraction | Update the last checkpoint if using end-of-block balances |
Stores the caller's _totalSupply as the denominator | Agreement with actual eligible supply needs a separate guarantee | Validate fixed supply or record supply history |
| Updates the offset after transferring, without checking the return value | External calls, actual receipts, and claim state must agree | Safe transfers, receipt-based accounting, state ordering, and reentrancy protection |
These observations can be traced to
_balanceSnapshot,
depositIncome,
and withdrawIncome.
The improvements in the last column are not guarantees already supplied by the original.
Early claims are another choice. Either allow each completed period to be claimed once or preserve how far a partial claim has been paid. Campaign creation and suspension need an authority policy, and tests must enforce the invariant that cumulative payouts never exceed the actual deposit.
Cost remains as well. The original scans a holder's checkpoints for every unprocessed campaign. With 100 campaigns and 1,000 checkpoints, that may mean roughly 100,000 segment checks. This is a workload example, not a gas measurement. A pull model avoids enumerating every holder; it does not bound one holder's claim cost.
A Contract Can Still Be Made More Robust
A checkpoint can also store cumulative balance × blocks up to the previous change. With fixed supply, subtracting the
cumulative values at the endpoints gives the holding area, and sorted checkpoints can be located by binary search.
Claims can process a bounded number of campaigns, while actual deposits, payouts, and remainders share one accounting model.
Changing supply requires another step. Consider a separate example with equal-length segments and equal income per block: A keeps ten tokens, but the total supply grows.
| Segment | A's balance | Total supply | A's share |
|---|---|---|---|
| First half | 10 | 100 | 10% |
| Second half | 10 | 200 | 5% |
The average of the segment shares is 7.5%. Adding balances and supplies separately before dividing produces instead. The method cannot be generalized as the ratio of two cumulative totals. It needs aligned balance-and-supply segments, or a separate index that accumulates reward per unit of ownership as income accrues. Those are choices for a future implementation.
Working Without a Hard Fork Led to a Question for the Ledger
The prototype uses ordinary EVM storage, block.number, and ERC-20 calls. Being able to experiment with rights in a
new token without introducing instructions or consensus rules was a real advantage of the contract layer.
It does not apply automatically to all existing ERC-20s. Integration would require an upgrade, migration, or wrapper, and pre-integration history does not appear in this contract by itself. The records describe address balances, not customer allocations in a custodian's subledger or legal beneficial ownership. Here, proof of holding period means auditable reconstruction from onchain records, not a new cryptographic proof.
Recording these histories asset by asset also reveals repetition: many tokens need similar storage and query rules. What would change if the ledger treated them as shared functionality?
| Dimension | Contract implementation | Shared ledger functionality |
|---|---|---|
| Adoption | New token on an existing EVM | Protocol change or a new network |
| Policy | Fast asset-specific experiments | General, conservative rules |
| Storage | Per-token checkpoint structures | Potential optimization through shared recording |
| Queries | Asset-specific calculation and indexing | Standard historical queries and proofs |
| Failure scope | Primarily the contract | May affect consensus and other assets |
Shared functionality does not make per-asset data disappear. Storage and query improvements still require measurement. Decisions about end-of-block state, finalized heights, and who retains and supplies data and proofs after old history is pruned would move into the ledger's responsibilities.
Stronger contracts and offchain indexers remain alternatives. This feature alone does not inevitably require a new blockchain. Implementing it in a contract nevertheless made a concrete next question possible: what if the ledger understood a history that every token repeatedly needed?
That became one of several motivations behind nigo-protocol. This retrospective records the path from a small state model to a question about shared infrastructure, rather than a current feature list or delivery schedule.
Appendix: Interval Calculation and Verification Scope
The general formula and remaining verification work
For a holding segment and income period , the overlapping block count is:
The formula uses a fixed reference supply and inclusive endpoints. Summing holding areas and dividing just once can differ from the original's per-segment rounding. Every division in the main example is exact, so that difference does not arise there.
The 2026-09-07 revision checked the pinned source against A=70, B=30, total=100; the first-stage remainder of three from a 103-unit deposit; and the changing-supply counterexample. It did not deploy or execute the contract or verify gas costs or production security. An improved implementation still needs boundary tests for same-block transfers, early and repeated claims, changing supply, transfer failures, differences in actual receipts, and paginated claims.
An offchain computation could instead commit a distribution to a Merkle root, allowing each holder to claim using an inclusion proof. Moving computation requires policies for source-data availability, reproducibility, and disputes. Settlement responsibilities remain whichever layer is chosen.