A tokenized share has to survive a 4:1 split
One share becomes four. The token contract has to reflect that. There are three ways to do it, and two of them break the ecosystem around the token.
Mint to every holder
Iterate the holder set and mint the difference. Requires unbounded iteration, and is simply impossible for tokens sitting in AMM pools, vaults, and bridges that were never built to receive an airdrop.
Does not scale, does not reach contractsRebase
Redefine balanceOf so every balance scales. Every cached balance is now
wrong, every AMM invariant shifts under the pool, and every system that assumed
transfer(x) moves exactly x silently miscounts.
Move a multiplier
Leave balances alone. Publish a single 1e18-scaled ratio saying how many shares one
token now represents. Nothing moves, no Transfer fires, and integrations
opt in to the adjustment when they are ready.
balanceOf returns and transfer moves. Never changes on a corporate action.
SHARE-EQUIVALENT - what the holder economically owns. Changes on every corporate action.
The two rules
Both are enforced by the type system in this package, not merely documented.
TokenAmount and ShareAmount are distinct types, and prices carry
a runtime-inspectable unit tag.
A Chainlink Robinhood feed answer is already multiplier-adjusted
The answer is the price of one TOKEN, not one share. It is a total-return price: after a 4:1 split it does not move. To recover the price of the underlying share, divide by the multiplier.
sharePrice = answer × 1e18 ÷ uiMultiplier answer × uiMultiplier
To value a position, multiply the raw balance by the feed price. Using the share-equivalent balance there double-counts the corporate action.
Share-equivalent quantities are balance × multiplier ÷ 1e18
The raw balanceOf value stops being a share count the moment a corporate
action lands. The division truncates, matching the on-chain
balanceOfUI() exactly. Rounding up instead would let the sum of holder
balances exceed the total share supply.
shareEquivalent = balance × uiMultiplier ÷ 1e18
A 4:1 split, in full
Alice holds 100 tokens. The issuer runs a 4:1 split. Here is every number that matters, before and after.
The balance did not move and no Transfer event fired.
An indexer that only watches Transfer sees nothing at all, which is exactly
why UIMultiplierUpdated is the authoritative corporate-action signal.
| Quantity | Before | After | Moved? |
|---|---|---|---|
balanceOf(alice) | 100 | 100 | no |
uiMultiplier() | 1e18 | 4e18 | yes |
balanceOfUI(alice) | 100 | 400 | yes |
totalSupply() | unchanged | unchanged | no |
| Chainlink answer (per token) | $315.50 | $315.50 | no |
| Share price (answer ÷ multiplier) | $315.50 | $78.875 | yes |
| Alice's position value | $31,550 | $31,550 | no |
Transfer events emitted | - | none | no |
balanceOf as a
share count says Alice owns 100 shares; she owns 400. A
pricer multiplying the feed answer by the multiplier values her position at
$126,200, four times the truth. Both are asserted in the test suites.
Recipes
Node ≥ 20. viem is a peer dependency.
import { createPublicClient, http } from 'viem'
import { robinhood } from 'viem/chains'
import { readUiMultiplier, shareEquivalent, tokenAmount, toDecimalString } from 'erc8056'
const client = createPublicClient({ chain: robinhood, transport: http() })
const SGOV = '0x92FD66527192E3e61d4DDd13322Aa222DE86F9B5'
const multiplier = await readUiMultiplier(client, SGOV)
const balance = tokenAmount(1_000_000_000_000_000_000_000n) // 1000 SGOV tokens
console.log(toDecimalString(balance, 18)) // '1000' <- tokens
console.log(toDecimalString(shareEquivalent(balance, multiplier), 18)) // '1000.957519890990718' <- shares
Never print balanceOf as a share count. readUiMultiplier
resolves a pre-ERC-8056 token to 1e18 instead of throwing, and
propagates transport failures rather than absorbing them: answering
“the multiplier is 1.0” because an RPC timed out would misreport every
position on a token that has accrued.
import {
formatPrice, positionValue, sharePriceFromFeed,
tokenAmount, tokenPriceFromFeed, uiMultiplier,
} from 'erc8056'
const answer = 10_068_131_213n // live SGOV Chainlink answer, 8 decimals
const multiplier = uiMultiplier(1_000_957_519_890_990_718n)
const perToken = tokenPriceFromFeed(answer, 8) // rule 1: no adjustment
const perShare = sharePriceFromFeed(answer, 8, multiplier) // rule 1: DIVIDE
console.log(formatPrice(perToken)) // '100.68131213 per token'
console.log(formatPrice(perShare)) // '100.58499999 per share'
// Value a position with the RAW balance and the TOKEN price.
const value = positionValue(tokenAmount(10n * 10n ** 18n), perToken, 18)
console.log(value) // { value: 100681312130n, decimals: 8 } -> $1006.81312130
Passing perShare to positionValue is a compile error, and a
PriceUnitMismatchError at runtime if you force it past the type checker.
import {ERC8056} from "erc8056/contracts/ERC8056.sol";
contract MyStockToken is ERC8056 {
address public immutable transferAgent;
constructor(address agent) ERC8056("My Stock Token", "MST") {
transferAgent = agent;
}
function _authorizeCorporateAction() internal view override {
require(msg.sender == transferAgent, "not the transfer agent");
}
/// A 4:1 split. No balance moves, no Transfer fires.
function split() external {
_applySplit(4, 1);
}
}
Authorization is left abstract on purpose: a corporate action is the most sensitive
operation on a tokenized security, and the right guard is issuer-specific. See
contracts/ERC8056StockToken.sol for the deployable version with role
separation, issuance, and ERC-165.
This is live, not theoretical
93 of the 95 canonical Stock Tokens sit at exactly 1e18 today. Two do not,
and they prove the mechanism is load-bearing right now. Reproduce with
npm run verify:onchain.
Robinhood Chain mainnet (chain 4663) via https://rpc.mainnet.chain.robinhood.com
Block 14994060
Checking ERC-8056 invariants across all 95 canonical Stock Tokens...
95/95 tokens implement uiMultiplier()
totalSupplyUI() == truncating mulDiv(totalSupply, uiMultiplier, 1e18) on every one of them
Tokens with a corporate action already applied (uiMultiplier != 1.0):
SGOV 0x92FD66527192E3e61d4DDd13322Aa222DE86F9B5 uiMultiplier() = 1000957519890990718
= 1.000957519890990718 shares per token iShares 0-3 Month Treasury Bond
WEEK 0xc93a8c440CEa26D7445dF01729f193b27965099f uiMultiplier() = 2006182524271844660
= 2.00618252427184466 shares per token Roundhill Weekly T-Bill ETF
Rule 1 on live data (SGOV, feed 0xa0DF4ee0fFf975306345875E3548Fcc519577A11):
Chainlink answer 10068131213 (100.68131213 per TOKEN)
divided by the multiplier 100.58499999 per SHARE
the wrong way (multiply) 100.77771648 <- overstates the share price
Pre-ERC-8056 fallback (a real contract with no uiMultiplier()):
WETH 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73 implemented = false, resolves to 1e18 [OK]
PASS: 95 tokens verified, 2 with a live corporate action applied.
Rule 2 is the chain's own arithmetic
totalSupplyUI() read from each contract equals
mulDiv(totalSupply, uiMultiplier, 1e18) computed locally, on all 95
tokens, truncating. Round differently and you disagree with the chain here.
The non-1.0 multipliers are accruals
SGOV and WEEK are T-bill funds whose multipliers grow as income is distributed. Their
holders own more shares than their balances say, continuously. A tracker printing
balanceOf as shares is already wrong on these two today.
The fallback works on a real contract
WETH on chain 4663 has no uiMultiplier(), and
readMultiplierState reports implemented: false with a 1e18
multiplier rather than throwing or inventing a number.
uiMultiplierAtGeneration as
1000000000000000000 for every token. That was true when those snapshots were
taken. It is not true now. Read the multiplier live.
Verification
Every claim on this page is reproducible from a checkout.
| Command | What it proves |
|---|---|
npm run build:contracts | Compiles contracts/ with solc 0.8.30. Zero warnings, zero errors. |
npm test | 80 vitest tests, including the ABI fragments checked against the real compiler output. |
npm run test:sol | 31 Foundry tests: splits, reverse splits, fractional multipliers, rounding, scheduling, authorization, and 2 fuzz suites. |
npm run typecheck | tsc --noEmit under strict, exactOptionalPropertyTypes, noUncheckedIndexedAccess. |
npm run verify:onchain | The live mainnet output above. |