Corporate actions
without rebasing anything

ERC-8056 lets a tokenized share survive splits, reverse splits, and reinvested dividends without ever rewriting a balance. One global uiMultiplier() records how many shares one token represents. This is the reference implementation, and the explainer for the two rules every integration has to get right.

$ npm install erc8056 viem The two rules Why not rebase?

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 contracts

Rebase

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.

Breaks integrations that did nothing wrong

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.

ERC-8056
The price of that design is that the token now has two units, and mixing them up is the entire integration risk. Everything below is about keeping them straight.
TOKEN - what 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.

1

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.

2

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.

Before the split
balanceOf100
uiMultiplier1.0
feed answer$315.50
After the split
balanceOf100
uiMultiplier4.0
feed answer$315.50

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.

QuantityBeforeAfterMoved?
balanceOf(alice)100100no
uiMultiplier()1e184e18yes
balanceOfUI(alice)100400yes
totalSupply()unchangedunchangedno
Chainlink answer (per token)$315.50$315.50no
Share price (answer ÷ multiplier)$315.50$78.875yes
Alice's position value$31,550$31,550no
Transfer events emitted-noneno
The two failure modes this table rules out. A tracker printing 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.

Registry snapshots of this chain commonly record 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.

CommandWhat it proves
npm run build:contractsCompiles contracts/ with solc 0.8.30. Zero warnings, zero errors.
npm test80 vitest tests, including the ABI fragments checked against the real compiler output.
npm run test:sol31 Foundry tests: splits, reverse splits, fractional multipliers, rounding, scheduling, authorization, and 2 fuzz suites.
npm run typechecktsc --noEmit under strict, exactOptionalPropertyTypes, noUncheckedIndexedAccess.
npm run verify:onchainThe live mainnet output above.