See the full cost of a tokenized-stock trade, then see how a price guard can reject an expensive fill. Closing Bell compares a Solana xStock quote with the underlying share's reference price. It separates the cost of moving a liquidity pool from the wrapper premium already in that pool's price. A Solana program demonstrates how to enforce a price band when the trade settles.
Try the app · Watch the demo · Inspect the on-chain proof · Use the public API
Captured from the deployed overview in light mode. Its equity reference was stale when captured; the displayed prices illustrate the UI, not a current market quote.
When you buy a tokenized stock, the swap quote describes execution against its pool. It does not show how far that pool's price is from the share the token tracks. Closing Bell puts both numbers side by side.
| What you can do | Why it helps |
|---|---|
| Check a trade by ticker and size | See pool impact, wrapper premium, and total cost against the underlying share. |
| Inspect a position with a wallet address | Estimate the premium carried by xStocks in a read-only flow. |
| Explore the guard | See a devnet fill inside the band succeed and one outside it revert. |
| Use the API | Add the cost breakdown to another tool without a wallet or signup. |
The project's September 2026 sample found a $50,000 SPYx buy with 0.06% pool impact but 0.58% total cost against SPY. Those are historical measurements, not today's quote. The research section explains the method.
Only authorized participants can redeem wrappers 1:1, so retail traders cannot always arbitrage a price gap directly. Several measured xStocks traded above their underlying equity; TSLAx was a useful near-zero control. Pyth publishes a reference ratio, but a pool-relative swap quote does not show the full gap.
Jupiter measures impact against the pool's own mid, so the premium never appears. A $50,000 SPYx buy shows +0.06% impact and costs +0.58% against SPY — the invisible term is roughly ten times the visible one, and unlike impact it is present at every size, including one share.
Measured 2026-09-18 against mainnet. The pool mid is isolated by quoting both directions at
the same notional, which cancels the spread: mid = (buy_dev + sell_dev) / 2.
| ticker | pool mid vs equity | Pyth published ratio | diff | spread |
|---|---|---|---|---|
| SPY | +60 bp | +57 bp | +3 | 3 bp |
| TSLA | +2 bp | 0 bp | +2 | 10 bp |
| NVDA | +19 bp | +9 bp | +9 | 10 bp |
| AAPL | +35 bp | +27 bp | +9 | 43 bp |
| GOOGL | +7 bp | +19 bp | −12 | 49 bp |
Correlation 0.931 over n=5. TSLA's published ratio is 1.00000 and its pool mid is +2 bp; SPY's is 1.00571 and its mid is +60 bp. A feed-construction artifact would offset both alike. The premium is in the price people actually trade at.
The roughly $600,000 estimate applies each ticker's mean premium over 1,025 measurements to circulating supply at the time of analysis, rather than a single probe — after hours, two runs a minute apart have disagreed by 18 bp on TSLA and by $300,000 on the total. It is premium carried, not losses taken, and circulating supply includes the pools' own inventory.
npx tsx scripts/aggregate-premium.tsNo wallet or signup is needed to explore. The app uses Jupiter pool quotes and Pyth equity references, which can become stale. The mainnet band refuses to build a guarded swap if its equity reference is more than 300 seconds old.
| page | what you'll see |
|---|---|
/ |
A live $50,000 SPYx trade split into pool impact and wrapper premium |
/trade |
Any ticker and size, a premium-vs-size sweep, and the mainnet band check |
/position |
What a wallet's xStocks are really costing — read-only, never signs |
/proof |
Inspect linked devnet transactions or send a new test fill with the site's wallet |
/findings |
The investigation into why, null results included |
Or from a terminal:
curl "https://closing-bell-eight.vercel.app/api/truecost?ticker=SPYx¬ional=10000"Showing the number is enough for a human reading a quote. Agents, DCA bots, treasury execution and liquidations against stock collateral never read one, so the band has to be enforced where the trade settles.
The guard does not route swaps. An integrator can bracket an existing swap inside one transaction:
ix 0 record_pre_state snapshot balances + oracle price + band
ix 1 any swap Jupiter, unmodified, real routing, any venue
ix 2 verify_fill balance deltas -> realised price -> band check -> revert
Atomicity does the enforcement: if verification fails, the whole transaction reverts. The guard reads balances rather than routing trades or operating its own liquidity pool.
| Pyth read + band derivation | 3,369 CU |
| Full guarded fill | ~33,000 CU |
| Test suite | 8 cases, all passing |
Program DeAF1jFtXTweJnC8x1P5VkzYNcdiqC6EfGiz6uxhi8ig
| fill | guard | on-chain | |
|---|---|---|---|
| at the oracle price | 0 bp | allowed | tx |
| 6.8% above oracle | 679 bp | rejected | tx |
Captured from the deployed proof page in light mode. The linked transactions were already on the page; this screenshot did not send a new transaction.
Devnet has no tokenized-stock pool for this demonstration. Its middle instruction mints the test base and burns the test quote instead of routing a live xStock swap. The guard checks the resulting balance deltas, but this is a guard demonstration, not a production trade.
The rejected transaction carries the guard's own reasoning in its logs:
fill: BUY base_delta=100000000 quote_delta=119939292
realised=1199392920000 expected=1123027080300
deviation=679bps band=301bps
Custom: 6000 is OutsideBand. The two token transfers ahead of verify_fill had already
executed, and the buyer's balance is unchanged at 1100000000 — the whole transaction
rolled back. Whole guarded fill costs ~36,800 CU.
The 301 bp band is 200 bp closed-market, plus 1 bp confidence widening, plus 100 bp because no basis had been pushed for this market — the ageing path widening the band rather than assuming the centre is exact.
The /proof page sends a fresh one of these on every click, signed by the site's own
wallet. Nothing is simulated.
The guard program has not been deployed to mainnet. A second path uses Jupiter's existing minimum-output check there, subject to a fresh oracle reference.
Every Solana swap already carries an on-chain minimum-output check: Jupiter's route
instruction reverts with SlippageToleranceExceeded when the fill lands below the
threshold. That threshold is normally derived from the pool's own mid — which is exactly
why the premium is invisible: a pool sitting 54 bp rich reports 0 bp of slippage, because
it is measuring against itself.
Derive the threshold from the Pyth oracle instead and the primitive becomes an oracle band. Nothing of ours runs on-chain; the router enforces our number.
# example: a SPYx quote outside a 25 bp band is refused
curl "https://closing-bell-eight.vercel.app/api/guarded-swap?ticker=SPYx¬ional=10000&band=25"
# example: a TSLAx quote may fit the same band when the reference is fresh
curl "https://closing-bell-eight.vercel.app/api/guarded-swap?ticker=TSLAx¬ional=10000&band=25"The control ticker shows up in the product, not just the research: at one moment, at the same band, SPYx is refused at 49.3 bp and TSLAx goes through at 2.8 bp. Inside the band, the app simulates the fill against live mainnet and then lets your wallet sign it.
The threshold is checkable rather than claimed. Decoding the built mainnet transaction and reading the JUP6 route instruction back:
we computed -> slippageBps: 55 | minOutRaw: 2644063800
in the tx -> slippage_bps: 55 | quoted_out_amount: 2658845336
enforced min -> 2644221687 | at or above our minimum
Rounding always resolves against the trade and never against the user: the demanded amount rounds up, the tolerance rounds down. A test sweeps prices against bands and asserts that what Jupiter would enforce never falls below what the band requires.
Two paths, one band, honestly different in strength. This path bounds the output amount and nothing else — it cannot check confidence, the market clock or keeper basis drift, or verify the realised price from balance deltas after the fill. The guard program does all of that, and is composable by other programs besides.
One measurement per ticker cannot separate a structural premium from whatever happened to be on screen. Sampling every three minutes asks a better question — does each sample predict the next?
| tkr | premium | persistence (lag-1) |
|---|---|---|
| SPY | 54.9 ± 5.9 bp | 0.929 |
| AAPL | 27.4 ± 11.1 bp | 0.846 |
| NVDA | 12.3 ± 10.5 bp | 0.439 |
| TSLA | −3.5 ± 8.6 bp | 0.034 |
TSLA is a useful control. Its measured premium and lag-1 persistence were near zero; SPY and AAPL had larger premiums and stronger persistence. NVDA's lag-1 figure was lower, so the pattern is not uniform across the sample.
- Oracle staleness is not producing it. Premium against the oracle's own age correlates −0.34 to +0.02. A stale-price artifact would be strongly positive.
- Three samples discarded by a stated 1% spread rule: one genuine after-hours route failure at a 36% implied spread, two merely wide at ~1.2%. The 99th percentile of every other sample is 0.80%.
1,025 usable samples over 13.5 hours across five sessions, as of 2026-09-22 — intraday only, no overnight or weekend coverage. The sampler appends rather than overwrites, so a fresh analysis reports a larger set with means that have moved a little — the shape holds, the last digit does not.
The premium orders by dividend yield and is exactly zero for the one ticker in the set that pays no dividend:
| tkr | AUM $m | liquidity $k | dividend yield | premium |
|---|---|---|---|---|
| SPY | 72.9 | 7,343 | 0.98% | 57.1 bp |
| QQQ | 60.8 | 1,808 | 0.40% | 27.3 bp |
| AAPL | 51.9 | 656 | 0.32% | 26.6 bp |
| GOOGL | 56.5 | 457 | 0.23% | 19.3 bp |
| NVDA | 70.6 | 2,065 | 0.46% | 9.2 bp |
| TSLA | 83.6 | 1,345 | 0.00% | 0.0 bp |
Correlation with dividend yield is 0.887, against −0.235 for AUM and 0.780 for liquidity. Size and demand are ruled out by TSLA: the largest wrapper in the set by AUM, with healthy liquidity, carries a premium of exactly zero.
- SPY's ex-dividend date fell inside the build window. A ~$1.90 dividend on a $759 spot predicts a ~25 bp step at the open. Measured across the open: −5.4 bp. SPY's spread is 3 bp, so a 25 bp step would have been unmissable. No step.
- NVDA yields 0.46%, more than AAPL or GOOGL, yet carries the smallest premium of the three. That inversion is unexplained.
- The implied accrual window clusters at 7–10 months against a wrapper that launched roughly 14 months ago.
The mechanism is unresolved. The product can show the measured price gap without depending on one explanation for it.
npx tsx replay/pool-vs-equity.ts # the basis measurement
npx tsx scripts/analyze-basis.ts # persistence, dispersion, staleness confoundThe public API exposes the cost breakdown without a key or signup. Its GET endpoints are read-only. The POST routes build an unsigned mainnet transaction or send a devnet test transaction, so inspect their behavior before integrating them.
curl "https://closing-bell-eight.vercel.app/api/truecost?ticker=SPYx¬ional=10000"400 means the request was wrong and retrying will not help; 502 means a feed or route
could not be reached and retrying might. Responses carry a 15-second shared cache, well
inside the premium's own rate of change.
If you are also building for Stocklana, take the number. It costs one curl, and a
second independent implementation of this measurement is worth more to the finding than
exclusivity is worth to us.
replay/basis-dense.jsonl — every sample the basis sampler has taken, one JSON object per
line, at a three-minute cadence across four tickers:
{"t":1789776953,"sym":"NVDA","oracle":222.515,"oracleAge":955,
"buyBps":18.17,"sellBps":11.49,"midBps":14.83,"spreadBps":6.66}As far as we can tell, a public time series of the tokenized-equity basis at this cadence does not exist anywhere else. It is committed rather than summarised because the mechanism is still unexplained, and the honest thing to publish alongside an open question is the data that raised it. Error rows and wide-spread rows are kept, so anyone re-analysing it can choose their own exclusion rule.
npx tsx scripts/build-series-json.ts # regenerate the chart on /findings
keeper/run-sampler.cmd # keep samplingcd app && cp .env.example .env.local # fill in an RPC URL at minimum
npm install && npm run devEight cases, all passing from a clean clone. It takes three terminals' worth of setup, so
the exact commands are here rather than an anchor test that does not work on its own.
anchor build
anchor keys sync # a clean clone generates its own program keypair
anchor build # declare_id! changed, so build again
# terminal 2 — the mainnet Pyth account has to be cloned in
solana-test-validator --reset --url https://api.mainnet-beta.solana.com \
--clone D9uk39pqZMcnmtPP9WeC8cREUpKZmyXLga9mSQ79SphW \
--clone XsbEhLAtcf6HdfpFZ5xEMdqW8nfAvcsP5bdudRLJzJp
# back in terminal 1
solana airdrop 100 --url http://127.0.0.1:8899
export PROGRAM_ID=$(solana address -k target/deploy/closing_bell_guard-keypair.json)
anchor test --skip-local-validatorTwo things will bite otherwise: anchor test on its own tries to spawn surfpool and dies
if it is not installed (hence the hand-started validator and --skip-local-validator), and
PROGRAM_ID must be exported or the test falls back to the devnet address.
Cases: fill at oracle (allowed) · 6.8% above (reverts, balances roll back) · 1.5% above
(allowed inside band) · sell 6.8% below (reverts — the band is symmetric) · snapshot with no
swap (NoFillDetected, not a divide-by-zero) · keeper basis above the ceiling
(BasisOutOfBounds) · verify_fill pointed at a substituted token account
(TokenAccountMismatch) · cancel_pending reclaiming a stray snapshot.
An allowed fill has measured between 30,300 and 34,900 CU across runs; the arithmetic depends on the live oracle price, so the figure moves a little each time.
The guard is a primitive, not an app — the seatbelt, not the car.
Who needs it first:
- Liquidation engines holding tokenized stock as collateral. A liquidation priced against the pool rather than the equity mis-marks by the premium. At SPYx's measured 58 bp that is ~$580 per $100,000 liquidated, against the borrower, every time, silently.
- DCA and treasury bots. They never read a quote, so a UI warning cannot reach them — and buying weekly at a premium averaging 51 bp is a standing cost nothing on their dashboard reports.
- Anything routing size. The premium does not shrink with better execution, because it is not execution — it is present at one share and at ten thousand.
What ships next, in order:
- Mainnet deploy and switch the reference to the
Equity.US.*feeds. No program logic changes; the mainnet fork already exercises this path. - Retire the keeper-supplied basis. Pyth's
Crypto.*X/*.RRfeeds would close it outright if maintained on mainnet at a fresh shard — the one dependency outside our control. - One integration. A primitive with zero integrations is a demo. The honest next step
is a single lending protocol or execution bot wiring
record_pre_state/verify_fillaround its existing swap — no change to how they route.
What would tell us to stop: if the premium converges as redemption opens to more participants, the band stops binding and the guard becomes dead weight. That is a real possibility, and the measurement infrastructure would be the thing that told us.
Two independent bugs in this build had the same shape — the thing being tested worked, and the proof of it was what failed.
- The test suite asserted on error message strings.
skipPreflightsuppresses logs on the thrown error, so those assertions would have passed on any failure, including the wrong one. Now asserts on Anchor error codes. - The first devnet run lost the rejected transaction's signature.
sendAndConfirmTransactionthrows on a rejected fill, and the signature was scraped from the error text by a regex that did not match. The guard blocked the fill exactly as intended and the artifact proving it was thrown away. Now sends viasendRawTransactionso the signature is in hand before confirmation reports failure.
Both were caught by checking the evidence rather than the outcome. The same habit caught two bad numbers in the analysis: an estimated NVDA dividend yield that reversed a conclusion once verified, and a fabricated Pyth feed id that silently corrupted a correlation to −0.016. Figures that drive a conclusion were checked against their source; estimates are labelled.
Full measured record, including where evidence contradicted the original design: docs/day0-findings.md.
programs/closing-bell-guard/ Anchor program: config, market, clock, record/verify
app/ Next.js true-cost frontend + public API
scripts/deploy-devnet.ts Devnet setup + the blocked-fill transaction
scripts/fork-mainnet.sh Local validator forked from mainnet with the real SPYx pool
tests/guarded-fill.ts Eight enforcement cases
replay/pool-vs-equity.ts The basis measurement
replay/depth-at-size.ts Depth across notional tiers and thin names
replay/basis-dense.jsonl The full three-minute basis dataset
keeper/sample-feed-freshness.ts Is the Pyth push path needed at all?
Built with Anchor 1.2.0, solana-cli 4.1.2, pyth-solana-receiver-sdk 2.0.0, under WSL2. Anchor 0.31.1 cannot build this — see the toolchain notes in the findings doc.



{ "oracle": { "price": 773.56, "ageSecs": 13, "feed": "Equity.US.SPY/USD", "shard": 1 }, "pool": { "midPrice": 777.75, "fillPrice": 777.86, "venues": ["Raydium CLMM", "Byreal"] }, "breakdown": { "poolImpactBps": 1.4, // what your aggregator shows you "wrapperPremiumBps": 54.3, // what nothing shows you "trueCostBps": 55.7 } }