Pre-auditThree days of senior audit time for $1,500, credited in full toward your Zealynx audit.3 audit days, credited toward your auditSee how →
A Price From the Future Cost $23.75M: 5 Checks Before Your Tokenised RWA Launches

A Price From the Future Cost $23.75M: 5 Checks Before Your Tokenised RWA Launches

16 min
On this page▾

TL;DR — Quick Summary

  • A tokenised real-world asset can be fully compliant and still pay out on a price nobody should have accepted. The token checks who may hold it. What it is worth comes from a price someone signs off chain.
  • Reject price reports dated in the future, and reports older than your limit. In July 2026, a validly signed report from the future priced Bitcoin at $5,000 and 23.75M USDC left a vault in about five minutes. In our demo, the same trick took 23.75% of the vault.
  • Check every redemption against the age of the NAV and a second reference. In our demo, a NAV stuck at $1.00 for three days paid 1,000,000 for shares worth 920,000.
  • Run compliance on every transfer path, wrappers included. In our demo, a wallet the token refused to send to ended up holding 50,000 through a wrapper.
  • Put agent powers (forced transfer, freeze, mint) behind a multisig, a timelock and a cap, and settle async redemptions at the next NAV, not the one known at request time.

The video runs every attack and every fix on screen. The article below covers the same ground, with the code, if you would rather read it.

Introduction

Your token is compliant. Your price feed isn't.
In July 2026, stolen credentials priced Bitcoin at $5,000, and 23.75M USDC left a vault in about five minutes. About $39B of real-world assets live on chain as of October 2026 (rwa.xyz), and every one of them is priced by someone who signs a number off chain.
So before anything else, here is a dare. Find the line where your contract reads the price. Does it reject old prices, and prices from the future? If not, keep reading.
This article walks through the five checks we run before a tokenised asset launches, the fix for each one in code, and the one line to read today. Every demo runs locally: an anvil chain on 127.0.0.1, our own simplified stand-ins (an ERC-3643-style permissioned token, an identity registry, a signed price feed, a NAV feed, a perp vault, a redemption vault and an async redemption queue), test tokens, and anvil's public dev keys. None of it is real ERC-3643 / T-REX, oracle or fund code, and nothing touches a live network. The full harness is public: ZealynxSecurity/zealynx-demos, newtech-06-rwa.
If you are still choosing a token standard, we compare the two main ones in ERC-3643 vs ERC-1400 for RWA Compliance. This article is the hands-on version: five attacks you can run, and five fixes you can copy.

How a tokenised fund works

A tokenised real-world asset is a fund, a loan or a share that lives on chain as a token.
The issuer holds the real asset. Off chain, someone signs its value, the net asset value (NAV), and posts it through an oracle. On chain, the token checks every transfer against an identity registry. Standards like ERC-3643 do exactly this. And a vault mints and redeems shares at that NAV.
So two things carry all the risk: the price the contract believes, and every path a token can take without asking the registry. Checks 1, 2 and 5 are about the price. Checks 3 and 4 are about the paths.

1. A price from tomorrow: reports accepted from the future

On 15 July 2026, Ostium, a perpetuals exchange, lost 23.75M USDC from its OLP vault in about five minutes. Its off-chain systems were breached: the attacker held the credentials of an authorized oracle signer and of a keeper role. With them, the attacker pushed a validly signed price report dated in the future, with Bitcoin at $5,000 while it traded near $60,000. Open, close, repeat.
Ostium's post-mortem found no flaw in its contract logic or its multisigs. We use it here for one lesson only, about prices: a signature tells you who signed a report, not whether the report should be believed right now.

The demo

Our own stand-in perp vault checks the feed's signature, but not the report's timestamp:
1// contracts/src/PerpVault.sol
2function _verify(Report calldata r, bytes calldata sig) internal view {
3 bytes32 d = MessageHashUtils.toEthSignedMessageHash(keccak256(abi.encode(r.price, r.ts)));
4 if (ECDSA.recover(d, sig) != reporter) revert BadSignature();
5 _checkTime(r.ts);
6}
7
8/// Vulnerable: no time check at all.
9function _checkTime(uint64) internal view virtual {}
We sign a report dated two hours ahead, Bitcoin at $5,000. The real price is $62,000. The attacker opens a 5x long on the old report and closes on a fresh one:
1vault holds 240,000 tUSDC (LP liquidity)
2signed report: BTC $5,000 · ts = now + 2h
3$ attacker → open(1,000 tUSDC, 5x long, that report)
4 ✓ opened 1 BTC at $5,000
5$ attacker → close(fresh report: BTC $62,000)
6vault 240,000 → 183,000 (-23.75%)
7VAULT -23.75% · ATTACKER +57,000 tUSDC

The fix

A report can't be dated after the current block, or be older than your limit:
1// contracts/src/PerpVaultFixed.sol
2uint256 public constant MAX_AGE = 60; // seconds
3
4function _checkTime(uint64 ts) internal view override {
5 if (ts > block.timestamp || block.timestamp - ts > MAX_AGE) revert FutureReport();
6}
The same signed report is rejected:
1vault holds 240,000 tUSDC (LP liquidity)
2signed report: BTC $5,000 · ts = now + 2h
3$ attacker → open(1,000 tUSDC, 5x long, that report)
4 ✗ reverted: FutureReport
5vault 240,000 → 240,000 (0.00%)
6VAULT UNCHANGED · 240,000 tUSDC
A stolen key can still sign a price for right now. That is why check 2 exists, and why price reports need more than one signer.

2. Yesterday's NAV, today's cash: redemptions at a stale NAV

The demo

Our NAV feed stopped three days ago at $1.00. The real NAV, from an independent reference, is now $0.92. Our stand-in redemption vault pays whatever the feed says:
1// contracts/src/RedemptionVault.sol
2function redeem(uint256 amount) external {
3 uint256 nav = _nav();
4 shares.transferFrom(msg.sender, address(this), amount);
5 usdc.transfer(msg.sender, amount * nav / 1e18);
6}
7
8/// Vulnerable: trusts the feed blindly.
9function _nav() internal view virtual returns (uint256) {
10 return feed.nav();
11}
Someone redeems a million shares and gets paid a million. Fair value was 920,000. The holders who stay cover the difference:
1NAV feed: $1.00 · updated 3 days ago
2reference NAV: $0.92
3$ redeemer → redeem(1,000,000 shares)
4 paid 1,000,000 tUSDC · fair 920,000
5OVERPAID 80,000 tUSDC (+8.70%)

The fix

Every redemption checks how old the NAV is, and how far it sits from a second reference. Too old, or too far, and it waits. In the demo, the limits are 1 day and 2%:
1// contracts/src/RedemptionVaultFixed.sol
2function _nav() internal view override returns (uint256 nav) {
3 nav = feed.nav();
4 uint256 r = ref.nav();
5 if (block.timestamp - feed.updatedAt() > 1 days) revert StaleNAV();
6 if ((nav > r ? nav - r : r - nav) * 100 > r * 2) revert StaleNAV();
7}
1NAV feed: $1.00 · updated 3 days ago
2reference NAV: $0.92
3$ redeemer → redeem(1,000,000 shares)
4 ✗ reverted: StaleNAV
5NOTHING PAID · shares kept 1,000,000
The one day and the 2% are our demo's configuration, not industry figures. Pick limits that match how often your fund really strikes a NAV, and treat a missed update as a reason to pause, not to pay. More on staleness in our glossary.
Tokenising a fund, credit or equities? Send us the design before you launch. We will tell you where it breaks, and what it would take to make it safe. Request a design review →

3. KYC checked, side door open: a wrapper that skips compliance

The demo

This wallet failed KYC. Our stand-in token says it can't receive: canTransfer(alice → mallory) is false, and a direct transfer reverts. But the issuer whitelisted a wrapper, a contract that holds the token and issues its own ERC-20 shares, and those shares ask nobody:
1// contracts/src/Wrapper.sol
2function wrap(uint256 amount) external {
3 token.transferFrom(msg.sender, address(this), amount);
4 _mint(msg.sender, amount);
5}
Alice wraps 50,000 and sends the wrapped shares to Mallory. Mallory now holds the exposure, and the registry never saw it:
1canTransfer(alice → mallory): false
2$ alice → token.transfer(mallory, 50,000)
3 ✗ reverted: NotEligible
4$ alice → wrap(50,000) · send wrapped → mallory
5 ✓ sent
6mallory: tFUND 0 · wrapped 50,000
7canTransfer: false · YET HOLDS 50,000 WRAPPED
Wrappers, vaults, lending markets, bridges: anything that holds the token for someone else is a side door. We cover the same trap from the standards side in the wrapper trap.

The fix

Run the compliance check in the core transfer function, not in a few entry points, and make any wrapper check its own holders against the same registry. In the demo, every wrapped-token movement calls the underlying token's check:
1// contracts/src/WrapperFixed.sol
2function _update(address from, address to, uint256 amount) internal override {
3 if (to != address(0) && !token.canTransfer(from, to)) revert NotEligible();
4 super._update(from, to, amount);
5}
1canTransfer(alice → mallory): false
2$ alice → token.transfer(mallory, 50,000)
3 ✗ reverted: NotEligible
4$ alice → wrap(50,000) · send wrapped → mallory
5 ✗ reverted: NotEligible
6mallory: tFUND 0 · wrapped 0
7BLOCKED · MALLORY HOLDS 0

4. One key, every holder: agent powers with no delay

Regulated tokens give an agent role real power, for lost wallets and court orders: freeze, force a transfer, mint. ERC-3643 defines these agent functions, and they are there for good reasons. The question is who holds them, and how fast they act.

The demo

In our stand-in token, forcedTransfer sits on one agent key, with no delay and no cap, and batchForcedTransfer lets that key move every holder's balance in a single transaction:
1// contracts/src/RwaToken.sol
2/// Recovery tool (lost keys, court orders). Vulnerable: one key, instant, unlimited.
3function forcedTransfer(address from, address to, uint256 amount) public virtual {
4 if (msg.sender != agent) revert NotAgent();
5 _transfer(from, to, amount);
6}
The agent key is stolen. One transaction, and twelve holders become zero:
112 holders · 1,200,000 tFUND
2agent key 0x3C44…93BC is stolen
3$ stolen key → batchForcedTransfer(12 → attacker)
4 ✓ one transaction
5holders with a balance: 12 → 0
6attacker holds 1,200,000 tFUND
71 TX · 12 HOLDERS → 0 · 1,200,000 TAKEN

The fix

Put agent powers behind a multisig and a 48-hour timelock, with a cap on every operation. In the demo, a forced transfer no longer moves tokens. It is capped, queued for 48 hours and announced with an event; a guardian can cancel it during the delay:
1// contracts/src/RwaTokenFixed.sol
2function forcedTransfer(address from, address to, uint256 amount) public override {
3 if (msg.sender != agent) revert NotAgent();
4 if (amount > maxForced) revert OverCap(); // capped
5 ops.push(Op(from, to, amount, block.timestamp + DELAY, false)); // queued
6 emit ForcedTransferQueued(ops.length - 1, from, to, amount, block.timestamp + DELAY); // visible
7}
Same key, same call:
112 holders · 1,200,000 tFUND
2agent key 0x3C44…93BC is stolen
3$ stolen key → batchForcedTransfer(12 → attacker)
4 ✗ reverted: OverCap · cap 1,000 per operation
5$ stolen key → forcedTransfer(1 holder, 1,000)
6 queued: 1,000 tFUND · executes in 48h · 1 event
7holders with a balance: 12 → 12
812 HOLDERS KEEP THEIR BALANCES · 1 OP QUEUED
A real court order can wait two days. An attacker can't. The 48 hours and the cap are our demo's configuration; set yours from how fast your team can see and cancel a queued operation.

5. Redeem early, others pay: async redemptions priced at request time

Many funds redeem in two steps: request today, settle later. This is asynchronous settlement.

The demo

If the price is locked at the request, anyone who sees a loss coming requests first. Our stand-in queue settles at the NAV of the epoch the request was made in:
1// contracts/src/AsyncRedeem.sol
2/// Vulnerable: the price that was known when the request was made.
3function _settleNav(Request storage r) internal view virtual returns (uint256) {
4 return navAt(r.epoch);
5}
A redemption is requested at $1.00, an 8% loss is struck, and the request settles at the old price. No hack needed, just arithmetic, every time the NAV moves:
1fund: 2,000,000 shares · NAV $1.00 (epoch 0)
2$ redeemer → requestRedeem(1,000,000) at epoch 0
3loss: assets -8.00% · NAV $0.92 (epoch 1)
4$ settle(request)
5 redeemer paid 1,000,000 · fair 920,000
6 others' share: $0.84 vs NAV $0.92
7 others lose 80,000 of a 1,000,000 stake (-8.00%)
8REDEEMER +80,000 · OTHERS -80,000

The fix

Settle every request at the next NAV after it was made, never the one already known. This is forward pricing:
1// contracts/src/AsyncRedeemFixed.sol
2function _settleNav(Request storage r) internal view override returns (uint256) {
3 if (r.epoch + 1 > epoch()) revert NotYetPriced();
4 return navAt(r.epoch + 1);
5}
1fund: 2,000,000 shares · NAV $1.00 (epoch 0)
2$ redeemer → requestRedeem(1,000,000) at epoch 0
3loss: assets -8.00% · NAV $0.92 (epoch 1)
4$ settle(request)
5 redeemer paid 920,000 · fair 920,000
6 others' share: $0.92 vs NAV $0.92
7 others lose 0 of a 1,000,000 stake (0.00%)
8PAID AT $0.92 · OTHERS LOSE 0
The same request settles at the new NAV, and nobody gains.

The checklist

Screenshot this one.
  1. Price reports signed by more than one key, never from the future, never older than your limit.
  2. Staleness and deviation checks on every redemption.
  3. Every transfer path runs compliance, wrappers included.
  4. Agent powers behind a multisig, a timelock and caps.
  5. Redemptions settle at the next NAV, not the one at request time.
Then do the one thing from the introduction. Open the function that accepts your price or your NAV. Check that it rejects prices older than your limit, and any price dated in the future. If it doesn't, that is check number 1. Fix that first.

FAQ

1. What happened in the Ostium exploit in July 2026?
On 15 July 2026, 23.75M USDC left Ostium's OLP vault in about five minutes. Ostium is a perpetuals exchange, not an RWA issuer; we use it for the oracle lesson only. Its off-chain systems were breached, and with the credentials of an authorized oracle signer and a keeper role, the attacker pushed a validly signed price report dated in the future, with Bitcoin at $5,000 while it traded near $60,000, and opened and closed positions against it repeatedly. Ostium's post-mortem found no flaw in its contract logic or its multisigs. The lesson for builders: a valid signature does not make a price current. Reject reports from the future and reports older than your limit.
2. How do I stop a signed price report from being replayed or post-dated?
Check the report's timestamp on chain, not only its signature: reject any report dated after the current block, and any report older than a maximum age. In our demo, a report dated two hours ahead priced BTC at $5,000 against a true $62,000, and the vault lost 23.75% (240,000 → 183,000 tUSDC). With if (ts > block.timestamp || block.timestamp - ts > MAX_AGE) revert FutureReport(), the same report was rejected and the vault was unchanged. Require more than one signer as well, because a stolen key can still sign a price for right now.
3. Why does a stale NAV hurt the holders who stay?
A redemption at a stale NAV pays out more than the shares are worth, and the difference comes out of the assets backing everyone else. In our demo, the NAV feed last posted $1.00 three days earlier while the reference NAV was $0.92, and redeeming 1,000,000 shares paid 1,000,000 against a fair 920,000, an overpayment of 80,000 (+8.70%). Requiring the NAV to be at most one day old and within 2% of an independent reference made the same redemption revert with StaleNAV.
4. Can a wrapper or vault bypass ERC-3643 compliance?
Yes, if the wrapper is whitelisted and its own shares move without asking the registry. In our demo, the token refused a direct transfer to a wallet that failed KYC, but the same wallet ended up holding 50,000 wrapped shares. Run compliance in the core transfer path, and make any wrapper, vault, lending market or bridge that holds the token check its own holders against the same registry. With the wrapper's _update calling token.canTransfer(from, to), the wrapped transfer reverted with NotEligible.
5. How should agent powers like forced transfer be protected?
Agent powers (freeze, forced transfer, mint) exist for lost wallets and court orders, so keep them, but put them behind a multisig, a timelock and a per-operation cap. In our demo, one stolen agent key moved all 12 holders' balances, 1,200,000 tokens, in a single transaction. With a cap of 1,000 per operation and a 48-hour queue with an event, the same call reverted with OverCap, a capped call was only queued, and all 12 holders kept their balances.

Glossary

TermDefinition
Real World Asset (RWA)Physical or traditional financial assets tokenized on blockchain, such as real estate, bonds, or commodities.
Net Asset Value (NAV)The value of a fund's assets minus its liabilities, usually quoted per share; a tokenised fund mints and redeems at it.
ERC-3643A token standard for permissioned security tokens that integrates identity verification and compliance checks directly into transfer logic.
Identity RegistryERC-3643 component mapping wallet addresses to verified on-chain identities and enabling compliance checks before transfers.
KYC (Know Your Customer)The identity checks a regulated issuer runs on an investor before that investor may hold its tokens.
Forced TransferAdministrative capability allowing issuers or controllers to move security tokens without the holder's private key.
TimelockSmart contract mechanism enforcing a mandatory delay between initiating and executing critical actions.
Forward PricingExchange rate determination at fulfillment time rather than request time, preventing arbitrage from stale NAV data in asynchronous vaults.

Build it safely

Every attack and fix in this article is runnable on a local chain: ZealynxSecurity/zealynx-demos, newtech-06-rwa. Clone it, run each demo vulnerable and then with --fixed, and compare them with your own token, feed and vault. The contracts there are simplified teaching stand-ins, not real ERC-3643 / T-REX, oracle or fund code, and they say nothing about who was at fault in any real incident.
Tokenising a fund, credit or equities? Send us the design before you launch. We will tell you where it breaks, and what it would take to make it safe. Request a design review →

Working auditors in your corner, all year

Zealynx Insiders: weekly live sessions, 1:1 advisory, pair-auditing, and Krait runs on your code, from an audit firm that publishes its reports.

Carlos (Bloqarl)
Written by

Founder at Zealynx

Keep reading
GENIUS Act Stablecoin Security: 5 Things Your Contract Must Get Right
Smart contractsOct 19

GENIUS Act Stablecoin Security: 5 Things Your Contract Must Get Right

16 min read
A $292M Bridge Hack Came Down to One Verifier: 5 Checks Before Launch
Smart contractsOct 28

A $292M Bridge Hack Came Down to One Verifier: 5 Checks Before Launch

17 min read
EIP-7702 Security: 5 Delegate Contract Bugs and the Fix for Each
Smart contractsOct 25

EIP-7702 Security: 5 Delegate Contract Bugs and the Fix for Each

18 min read