From 47e392fd4334c44f43b953d3813f2848902842c6 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Tue, 22 Sep 2026 23:29:16 +0000 Subject: [PATCH] Accrue interest and funding by the wall clock, and correct the halt section for Alpenglow The lending market and the perpetual futures venue now accrue for the seconds on the Clock's timestamp rather than for elapsed slots, following the companion repository. A rate quoted per year or per hour only became a rate per slot through a guess at the slot length, and the reduction to 200 ms slots doubled what a slot-counted rate charges. A leader can move the timestamp by a second or two, which against an annual rate is about a hundred-millionth of the debt, so the liquidation-line argument for slots does not hold for accrual. Freshness stays in slots. Time chapter: the accrual section, the shorter-slot section (with the SPL token-lending constant as a War Story), the programs table, What You Learnt, and the clock decision tree's fourth question, which now asks about a rate or a deadline quoted in human time. The halt section said the first block after a restart carries a timestamp hours later and that the vault's sixty-second window refuses the old price. Under Alpenglow the timestamp cannot jump: it trails real time and catches up, which the companion's vault code already handles with a restart check. Both statements now say so. Lending, Perpetual Futures, Arithmetic of Money, Operating Financial Software, the glossary, the funding-index and rate-curve figures, and the Amazon description follow. Claude-Session: https://claude.ai/code/session_01CXqcBq93zfcZ2F8jeoSaez --- AMAZON-DESCRIPTION.md | 2 +- appendices/glossary.md | 4 +- .../figures/lending-rate-curve.svg | 2 +- .../figures/perps-funding-index.svg | 8 +-- .../lending.md | 4 +- .../perpetual-futures.md | 10 +-- .../operating-financial-software.md | 2 +- .../the-arithmetic-of-money.md | 6 +- .../figures/clock-decision-tree.svg | 4 +- solana-building-blocks/time.md | 63 ++++++++++--------- 10 files changed, 54 insertions(+), 51 deletions(-) diff --git a/AMAZON-DESCRIPTION.md b/AMAZON-DESCRIPTION.md index f838af60..2ae93263 100644 --- a/AMAZON-DESCRIPTION.md +++ b/AMAZON-DESCRIPTION.md @@ -10,7 +10,7 @@ Written for engineers shipping software that holds other people's money, and for **Part I** builds the fundamentals: Solana's accounts, tokens, PDAs, and time, then financial software as a state machine, the arithmetic of money, offchain truth, the adversary, and operating a program in production. Exploit classes are taught through the incidents that made them famous, among them the $2.6B SPL token-lending rounding bug, Cashio ($52M), Wormhole ($325M), and Mango Markets ($116M). -**Part II** walks ten complete, tested programs end to end: an escrow, an all-or-nothing token fundraiser, a parimutuel betting market, an order book exchange with a critbit matching engine, a constant-product AMM, an oracle-priced proprietary AMM, a lending and borrowing market with per-slot interest and a liquidation engine, an onchain index fund, a fully collateralized options venue, and a perpetual futures venue with funding, margin, and pre-funded solvency. Every chapter gives the account model, the instruction handlers walked as a story, the math in minor units with its rounding directions, the invariants, the failure modes, the tests that catch each one, and the incentives of everyone involved, operator and user alike. Account maps, state machines, and value flows are drawn in one visual language across all ten, so the programs can be read against each other. They have not been explained side by side in one place before. +**Part II** walks ten complete, tested programs end to end: an escrow, an all-or-nothing token fundraiser, a parimutuel betting market, an order book exchange with a critbit matching engine, a constant-product AMM, an oracle-priced proprietary AMM, a lending and borrowing market with per-second interest and a liquidation engine, an onchain index fund, a fully collateralized options venue, and a perpetual futures venue with funding, margin, and pre-funded solvency. Every chapter gives the account model, the instruction handlers walked as a story, the math in minor units with its rounding directions, the invariants, the failure modes, the tests that catch each one, and the incentives of everyone involved, operator and user alike. Account maps, state machines, and value flows are drawn in one visual language across all ten, so the programs can be read against each other. They have not been explained side by side in one place before. In the tradition of *TCP/IP Illustrated*, the book teaches the layer that does not change. Frameworks churn. The account model has been stable since Solana launched, the arithmetic of money will not change, and neither will the desire of adversaries to take your assets. diff --git a/appendices/glossary.md b/appendices/glossary.md index a89c8dd5..2338ab6a 100644 --- a/appendices/glossary.md +++ b/appendices/glossary.md @@ -72,7 +72,7 @@ A recent block's hash, included in every transaction to bound its lifetime. When ### borrow accumulation factor -A single number per reserve that starts at 1.0 and only grows, compounding the borrow rate over elapsed slots. A borrower's live debt is their stored principal multiplied by it, so interest accrues for everyone without any loan being touched. The lending example calls the field `borrow_accumulation_factor`; see the *Lending and Borrowing* chapter. +A single number per reserve that starts at 1.0 and only grows, compounding the borrow rate over elapsed seconds. A borrower's live debt is their stored principal multiplied by it, so interest accrues for everyone without any loan being touched. The lending example calls the field `borrow_accumulation_factor`; see the *Lending and Borrowing* chapter. ### built-in address @@ -213,7 +213,7 @@ The last moment an [option](#option) can be exercised. After it the writer recla ### exponentiation by squaring -Computing (1 + r)ⁿ for large n in log₂(n) multiplications instead of n of them, each one checked and floored the same way. How to compound a rate over many elapsed slots in a single update. See [Newton's method (integer square root)](#newtons-method-integer-square-root) for roots, and the *Arithmetic of Money* chapter. +Computing (1 + r)ⁿ for large n in log₂(n) multiplications instead of n of them, each one checked and floored the same way. How to compound a rate over many elapsed seconds in a single update. See [Newton's method (integer square root)](#newtons-method-integer-square-root) for roots, and the *Arithmetic of Money* chapter. ### faucet diff --git a/building-financial-products-in-solana/figures/lending-rate-curve.svg b/building-financial-products-in-solana/figures/lending-rate-curve.svg index d4f70dcd..6cb1e194 100644 --- a/building-financial-products-in-solana/figures/lending-rate-curve.svg +++ b/building-financial-products-in-solana/figures/lending-rate-curve.svg @@ -43,7 +43,7 @@ - refreshes, over five weeks of slots + refreshes, over five weeks 1.0 ≈ 1.0107 Bob's debt = principal × factor: $750 → ≈ $758 diff --git a/building-financial-products-in-solana/figures/perps-funding-index.svg b/building-financial-products-in-solana/figures/perps-funding-index.svg index 22ee3641..dd15db13 100644 --- a/building-financial-products-in-solana/figures/perps-funding-index.svg +++ b/building-financial-products-in-solana/figures/perps-funding-index.svg @@ -4,7 +4,7 @@ - slots + seconds cumulative_funding 0 @@ -22,9 +22,9 @@ - run once: rate 5,000 per slot, - 5,000 USDC position, 2,000 slots - (7 min at 200 ms slots) → 50 USDC of funding + run once: rate 5,000 per second, + 5,000 USDC position, 2,000 seconds + (about 33 minutes) → 50 USDC of funding shorts outweigh longs: index falls, shorts pay diff --git a/building-financial-products-in-solana/lending.md b/building-financial-products-in-solana/lending.md index 671274cd..2de23b69 100644 --- a/building-financial-products-in-solana/lending.md +++ b/building-financial-products-in-solana/lending.md @@ -66,11 +66,11 @@ Carol thinks SpaceX is overvalued and wants to profit if it falls. Her trade is Weeks pass, and the loans accrue interest without a single token moving. The mechanism is the **borrow accumulation factor**. -Each reserve stores one number that starts at 1.0 and only grows. On every `refresh_reserve()`, the program computes the current borrow rate, multiplies it by the slots elapsed, and grows the factor: new factor = old factor × (1 + rate per slot × elapsed slots). Rather than updating every loan, the reserve stores each borrower's `borrowed_principal` and grows the one shared `borrow_accumulation_factor`. A live debt is that principal times the current factor, rounded *up*, because debt rounds against the debtor. No loan is ever visited individually. Tokens move only when people repay or redeem, like the order book's unsettled balances but across time instead of across fills. +Each reserve stores one number that starts at 1.0 and only grows. On every `refresh_reserve()`, the program computes the current borrow rate, multiplies it by the seconds elapsed, and grows the factor: new factor = old factor × (1 + rate per second × elapsed seconds). Rather than updating every loan, the reserve stores each borrower's `borrowed_principal` and grows the one shared `borrow_accumulation_factor`. A live debt is that principal times the current factor, rounded *up*, because debt rounds against the debtor. No loan is ever visited individually. Tokens move only when people repay or redeem, like the order book's unsettled balances but across time instead of across fills. The rate itself comes from a **kinked utilization curve**. Utilization is borrowed ÷ (available + borrowed). Below the kink at 80% utilization, the rate climbs gently from 2% toward 20% APR; above it, steeply toward 150%. Above the kink, when nearly everything is lent out, the rate spikes, which pushes borrowers to repay and pulls new suppliers in with yield. -Those percentages are annual, and the accrual is per slot, so the conversion divides by a slots-per-year figure the reserve stores as `slots_per_year`. That divisor is the cluster's slot time written as a count, so it is stored in the config rather than in the code: shorten the slot and the same curve charges more per day, with no number in the program changed to say so. The *Time* chapter covers where the current figure comes from. +Those percentages are annual, and the accrual is per second, so the conversion divides by the seconds in a year. The seconds elapsed are the Clock's timestamp minus the one the reserve stored at its last accrual, `last_accrual_timestamp`. Counting seconds rather than slots keeps a borrower's cost to the quoted rate whatever the network's slot length, and the *Time* chapter covers why. Changing the curve with `update_reserve_config()` accrues at the old rates first, so the seconds already elapsed are charged at the rates that applied to them. Of the interest that accrues, the reserve factor (10%) is set aside in `accumulated_protocol_fees`, floored, in the suppliers' favor; the rest belongs to the suppliers through the rising share price. diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index 65d33fd7..7317d52d 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -20,14 +20,14 @@ Maria creates the pool and is its `authority`; her revenue is the open and close Financial state on Solana is accounts, and this one transaction created the three the market needs. -The **pool** is a PDA with seeds `"pool"`, the collateral mint, and the oracle feed, so one market exists per collateral-and-feed pair. Its struct, `Pool`, carries the risk parameters set at creation (`max_leverage`, `maintenance_margin_bps`, `open_fee_bps`, `close_fee_bps`, `liquidation_fee_bps`, `max_confidence_bps`, `funding_rate_per_slot`) and the live accounting this chapter keeps returning to: +The **pool** is a PDA with seeds `"pool"`, the collateral mint, and the oracle feed, so one market exists per collateral-and-feed pair. Its struct, `Pool`, carries the risk parameters set at creation (`max_leverage`, `maintenance_margin_bps`, `open_fee_bps`, `close_fee_bps`, `liquidation_fee_bps`, `max_confidence_bps`, `funding_rate_per_second`) and the live accounting this chapter keeps returning to: - `liquidity`: what the LPs own. It grows with deposits, trader losses, and funding; it shrinks with withdrawals and trader profits. - `reserved_liquidity`: the portion of `liquidity` currently pledged as the maximum payout of open positions. - `total_collateral`: trader money held for open positions. It is tracked separately from `liquidity` because it is not the LPs' money. - `protocol_fees`: Maria's accrued fees, separate again. - `long_size`, `short_size`, `long_size_scaled`, `short_size_scaled`: per-side open-interest accumulators, which make solvency checkable without iteration. -- `cumulative_funding` and `last_funding_slot`: the funding index. +- `cumulative_funding` and `last_funding_timestamp`: the funding index. The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"`) and the token authority of the **custody vault** (seeds `"vault"`), a single token account holding LP liquidity, trader collateral, and fees together, and signs for both with its own seeds. The vault mixes custody; the three ledger fields keep the ownership straight, and the rule that makes that safe is the *Token Fundraiser* chapter's: balances are custody, state is truth. @@ -75,16 +75,16 @@ Each open creates a **position** PDA with seeds `"position"`, the pool, the owne ## Funding Is a Fee the Crowded Side Pays -A perpetual never expires, so what stops its price drifting away from the asset it tracks? On every venue the answer is **funding**: a periodic payment that makes the popular side pay for its popularity, nudging traders toward balance. This program implements funding in its simplest form: a pool-wide index, `cumulative_funding`, advances by `funding_rate_per_slot` every slot, in whichever direction the open interest leans; whichever side is heavier pays. Each position remembers the index at its entry (`entry_funding`), and settlement charges size × the index movement since entry, at `FUNDING_PRECISION` fixed point. For example, with a rate of 5,000 per slot, a 5,000 USDC position held through 2,000 slots owes 50 USDC. +A perpetual never expires, so what stops its price drifting away from the asset it tracks? On every venue the answer is **funding**: a periodic payment that makes the popular side pay for its popularity, nudging traders toward balance. This program implements funding in its simplest form: a pool-wide index, `cumulative_funding`, advances by `funding_rate_per_second` every second, in whichever direction the open interest leans; whichever side is heavier pays. Each position remembers the index at its entry (`entry_funding`), and settlement charges size × the index movement since entry, at `FUNDING_PRECISION` fixed point. For example, with a rate of 5,000 per second, a 5,000 USDC position held for 2,000 seconds owes 50 USDC. The example documents two simplifications: - **The payment goes to the pool**, to the LPs, rather than to the lighter side of traders. - **The rate is flat**, rather than scaling with how lopsided the book is. -Production funding is proportional to the imbalance and flows between the sides; the mechanism here teaches the accounting structure, an index plus per-position entry snapshots, which is the part that survives into every real implementation. The test `test_funding_charged_to_long` opens a lone long, warps two thousand slots, and asserts the pool collected exactly the funding the index accrued. To keep every other number in this chapter clean, the walkthrough pool sets the funding rate to zero, as the test suite's default market does. +Production funding is proportional to the imbalance and flows between the sides; the mechanism here teaches the accounting structure, an index plus per-position entry snapshots, which is the part that survives into every real implementation. The test `test_funding_charged_to_long` opens a lone long, lets two thousand seconds pass, and asserts the pool collected exactly the funding the index accrued. To keep every other number in this chapter clean, the walkthrough pool sets the funding rate to zero, as the test suite's default market does. -The rate is quoted per slot, so what a position costs per hour follows the cluster's slot time as well as the rate. A pool that outlives a shortening of the slot charges the heavier side more per hour than it was set up to. `set_funding_rate()` lets the pool operator bring it back in line, advancing the index at the old rate before storing the new one so the slots already elapsed are charged at the rate that applied to them. The *Time* chapter covers why any rate counted in slots needs that. +The rate is quoted per second of wall-clock time, and the index stores the Clock's timestamp at its last advance, so what a position costs per hour is the rate alone, whatever the network's slot length. `set_funding_rate()` lets the pool operator change the rate, advancing the index at the old rate before storing the new one so the seconds already elapsed are charged at the rate that applied to them. The *Time* chapter covers why funding counts seconds and freshness counts slots. ![](figures/perps-funding-index.svg) diff --git a/financial-programming-concepts-in-solana/operating-financial-software.md b/financial-programming-concepts-in-solana/operating-financial-software.md index 2bd74140..2f08856d 100644 --- a/financial-programming-concepts-in-solana/operating-financial-software.md +++ b/financial-programming-concepts-in-solana/operating-financial-software.md @@ -13,7 +13,7 @@ Financial code fails at the edges the happy path never reaches: the refund, the - **Test every attack by name.** Every rule in a program chapter's failure-modes section gets a test that tries to break it: the non-maker canceling an offer, the fee vault passed in place of the user vault, the second claim on a settled event. - **Assert invariants, not just account values.** Conservation (a round trip creates no value), monotonicity (an index never decreases), directional rounding (no path rounds in the user's favor). An invariant test catches a whole bug class; an assertion on one account's value catches one instance of it. -Every test in this list is cheap to write and fast to run. The test runs the compiled program in-process in milliseconds, warping the slot makes weeks of interest accrue inside one test, and an injected feed account sets whatever price the test needs. Cashio and Wormhole, in the *Adversary* chapter, each lost their funds through a handler that accepted a substituted account. The fifth rule above turns that attack into a test: the fee vault passed in place of the user vault. +Every test in this list is cheap to write and fast to run. The test runs the compiled program in-process in milliseconds, moving the clock forward makes weeks of interest accrue inside one test, and an injected feed account sets whatever price the test needs. Cashio and Wormhole, in the *Adversary* chapter, each lost their funds through a handler that accepted a substituted account. The fifth rule above turns that attack into a test: the fee vault passed in place of the user vault. ## Proving Goes Further Than Testing diff --git a/financial-programming-concepts-in-solana/the-arithmetic-of-money.md b/financial-programming-concepts-in-solana/the-arithmetic-of-money.md index 96a3e1b1..82517557 100644 --- a/financial-programming-concepts-in-solana/the-arithmetic-of-money.md +++ b/financial-programming-concepts-in-solana/the-arithmetic-of-money.md @@ -83,19 +83,19 @@ One note from the same program: degenerate values like zero can make invariants ## One Accumulation Factor Compounds Interest for Every Borrower -A lending market can have thousands of open loans, and every one of them owes more with each slot that passes. Written naively, interest is a loop: every slot, visit every loan and multiply its debt by one plus the rate. Onchain that loop cannot run: there is no transaction large enough to include every borrower's account. Even a crank working through the loans in batches would spend compute rewriting thousands of accounts to record interest nobody is collecting yet. +A lending market can have thousands of open loans, and every one of them owes more with each second that passes. Written naively, interest is a loop: every second, visit every loan and multiply its debt by one plus the rate. Onchain that loop cannot run: there is no transaction large enough to include every borrower's account. Even a crank working through the loans in batches would spend compute rewriting thousands of accounts to record interest nobody is collecting yet. The **accumulation factor** removes the loop. Each market stores one fixed-point number that starts at 1.0 and grows by the current rate each time anyone touches the market: ``` -new factor = old factor × (1 + rate per slot × elapsed slots) +new factor = old factor × (1 + rate per second × elapsed seconds) ``` Each loan stores only its principal, and a live debt is that principal multiplied by the current factor. One number moves instead of thousands, interest compounds across updates on its own, and no tokens move until someone repays or withdraws. The lending example calls the field `borrow_accumulation_factor`; the *Lending and Borrowing* chapter runs it. That update is an approximation of compounding, and it is the one the lending market runs. The exact form exists for the day a formula needs it: -- **The everyday update approximates.** Multiplying by (1 + rate × elapsed slots) is simple interest over the interval. True compounding over n slots is (1 + rate)ⁿ, a power; the approximation is a multiply. The lending market grows `borrow_accumulation_factor` this way in `refresh_reserve()`, and every handler that moves money requires that refresh to have run first, so the intervals stay short. With a tiny per-slot rate and short intervals, the difference from true compounding is smaller than anything a borrower could measure. +- **The everyday update approximates.** Multiplying by (1 + rate × elapsed seconds) is simple interest over the interval. True compounding over n seconds is (1 + rate)ⁿ, a power; the approximation is a multiply. The lending market grows `borrow_accumulation_factor` this way in `refresh_reserve()`, and every handler that moves money requires that refresh to have run first, so the intervals stay short. With a tiny per-second rate and short intervals, the difference from true compounding is smaller than anything a borrower could measure. - **When a formula needs the exact power, square.** For (1 + rate)ⁿ with a large n, do not multiply n times. Use repeated squaring instead: (1 + rate)⁸ is three squarings rather than seven multiplications, and in general about log₂(n) of them, each with checked math and floored the same way. This is **exponentiation by squaring**. No program in this book needs it, because the lending market refreshes often enough that the approximation stays closer than the rounding. It is the tool for a market that can sit untouched for a long stretch, where one catch-up update would otherwise treat months of interest as simple interest. The rate itself is a policy choice; the arithmetic above only applies whatever rate the market sets. Nearly every lending market sets it the same way, though: as a function of **utilization**, the share of the pool that is lent out. The rate rises gently up to a target utilization and steeply beyond it, so a pool that is nearly empty pays depositors more and charges borrowers more until deposits come back or loans are repaid. diff --git a/solana-building-blocks/figures/clock-decision-tree.svg b/solana-building-blocks/figures/clock-decision-tree.svg index 48439d00..021e3e23 100644 --- a/solana-building-blocks/figures/clock-decision-tree.svg +++ b/solana-building-blocks/figures/clock-decision-tree.svg @@ -39,13 +39,13 @@ no - Is the span days or longer, quoted + Is it a rate or a deadline quoted to somebody in human time? yes the wall clock - leave a margin wider than a few seconds + give a deadline a margin of seconds no diff --git a/solana-building-blocks/time.md b/solana-building-blocks/time.md index ca4f9f77..3792055a 100644 --- a/solana-building-blocks/time.md +++ b/solana-building-blocks/time.md @@ -22,7 +22,7 @@ The slot count is how many slots have gone by. Programs read the current count f Two things affect how much wall-clock time a number of slots adds up to, and anyone converting between the two should know both: - **A slot is not exactly the length the network intends.** The network sets a target, currently 200 milliseconds, and real slots run a little longer or shorter. A time counted in slots is accurate to around [PLACEHOLDER: measure slot drift % after 200 ms slots and Alpenglow go live].[^slot-time-live] A slot mostly runs long because its leader was slow or its block was skipped, so a slot-counted window usually covers a little more wall-clock time than it was sized for, which loosens it rather than tightening it. -- **The count stops when the chain does.** No block is produced during a halt, so the count stands still while the world outside keeps moving. That pauses per-slot accrual, which is usually what you want, and it makes an hours-old price look one slot old, which nobody wants. +- **The count stops when the chain does.** No block is produced during a halt, so the count stands still while the world outside keeps moving. That makes an hours-old price look one slot old. Slots are grouped into **epochs** of 432,000 slots, which is exactly a day at 200 milliseconds a slot, and a little longer in practice because real slots run long. Some changes to the network, a new slot length among them, take effect only when a new epoch begins. @@ -38,42 +38,44 @@ A leader can pick any time inside the permitted range, which moves the timestamp ![](figures/clock-decision-tree.svg) -A price three seconds stale can be worth a liquidation to whoever arranged the delay, so freshness counts slots. A contribution three seconds late is worth one contribution, and a margin covers it, so the fundraiser's week runs on the wall clock. The vault's annual fee runs on the wall clock too, because a few seconds against a year change it by nothing anybody can see. +A price three seconds stale can be worth a liquidation to whoever arranged the delay, so freshness counts slots. A contribution three seconds late is worth one contribution, and a margin covers it, so the fundraiser's week runs on the wall clock. Interest, funding, and the vault's annual fee run on the wall clock too. Each is a rate quoted to somebody per year or per hour, and a few seconds against a year change it by nothing anybody can see. The four questions rest on one comparison. A few seconds of wall clock stay a few seconds however long the span runs. Around [PLACEHOLDER: measure slot drift % after 200 ms slots and Alpenglow go live] of a slot count grows with the span, until across a week it is [PLACEHOLDER: measure hours of drift per week after 200 ms slots and Alpenglow go live]. -## Interest and Funding Accrue by Slot +## Interest and Funding Accrue by the Wall Clock -Two programs in this book accrue value over time, and both count it in slots: +Two programs in this book accrue value over time, and both count it in seconds: - **The lending market accrues interest on debt.** It carries one number per reserve, a **borrow accumulation factor** that grows each time anyone touches the market: ``` - new factor = old factor × (1 + rate per slot × elapsed slots) + new factor = old factor × (1 + rate per second × elapsed seconds) ``` - Elapsed slots is the current slot minus the slot of the last refresh, which the reserve stores. Rather than recalculating every borrower's debt, the market stores each borrower's principal and updates the one shared factor, so a borrower's current debt is their principal multiplied by that factor. Its rates are quoted annually, so the conversion divides by the slots in a year, and the reserve stores that divisor as `slots_per_year` rather than working it out. The *Arithmetic of Money* chapter covers how to build that factor safely. -- **The perpetual futures venue accrues funding on open positions.** It does the same, storing `cumulative_funding` alongside `last_funding_slot`. Its funding rate is set per slot to begin with, so the slot time is what decides the cost of an hour. + Elapsed seconds is the Clock's current timestamp minus the one the reserve stored at its last accrual, `last_accrual_timestamp`. Rather than recalculating every borrower's debt, the market stores each borrower's principal and updates the one shared factor, so a borrower's current debt is their principal multiplied by that factor. Its rates are quoted annually, so the conversion divides by the seconds in a year. The *Arithmetic of Money* chapter covers how to build that factor safely. +- **The perpetual futures venue accrues funding on open positions.** It does the same, storing `cumulative_funding` alongside `last_funding_timestamp`, and its operator sets the rate per second. -Both accumulate, both advance by elapsed slots, and both decide what somebody owes, which decides whether a position can be liquidated. An adversary running a leader could profit by shifting the time a few seconds at the liquidation line, so the accrual counts slots. The slot count's own error, around [PLACEHOLDER: measure slot drift % after 200 ms slots and Alpenglow go live] across a year, changes what a borrower pays, and the market owner corrects it by retuning `slots_per_year`. +Both accumulate, both advance by elapsed time, and both decide what somebody owes, which decides whether a position can be liquidated. A leader can move the timestamp by a second or two, and at a 20% annual rate two seconds of interest is about one hundred-millionth of the debt. A position that close to the liquidation line crosses it anyway if the leader waits two seconds, so moving the clock gains the leader nothing. A slot count would buy nothing here and cost a guess: a rate quoted per year becomes a rate per slot only through the slot length, which the network changes and does not deliver exactly. + +Both programs treat a timestamp at or before the stored one as no time passed, and leave the stored one where it is. The network already rejects a block whose time goes backwards, so this costs nothing, and it means no second is ever charged twice. ## How to Turn Time Into Slots -A freshness window is agreed in seconds, a borrower is quoted a rate per year, and each of those has to become a slot count before a program can use it. Divide the time by the length of a slot in milliseconds. Multiplying runs the same arithmetic backwards, for telling a person what a slot count or a per-slot rate comes to in their own units. +A freshness window is agreed in seconds, and it has to become a slot count before a program can use it. Divide the time by the length of a slot in milliseconds. Multiplying runs the same arithmetic backwards, for telling a person what a slot count comes to in their own units. The network's target slot length is 200 milliseconds at the time of writing.[^slot-simd] A program can get that length three ways: -- **From Anza's documentation, written into your program.**[^anza-docs] This is correct until the network next shortens the slot, and wrong from then on. Keep any rate that depends on the slot length in config, where a transaction can change it, which the section on a shortened slot covers. +- **From Anza's documentation, written into your program.**[^anza-docs] This is correct until the network next shortens the slot, and wrong from then on. Keep any window that depends on the slot length in config, where a transaction can change it, which the section on a shortened slot covers. - **From the feature gates.** Changes to the network ship as **features**, built into the validator software switched off and turned on across the network at the start of an epoch. Each shortening of the slot ships as one, so a program can check which of them are live and take the shortest, falling back to 400 milliseconds when none are.[^check-slot-time] A new slot time applies from the start of the epoch after its gate activates, not from the moment it does, so check the epoch as well as the gate. - **From the network itself, in future.** While no account reports the slot time in milliseconds at the time of writing, this is expected to change.[^onchain-params] Check Anza's documentation and the companion repository before writing either technique above, in case such an account now exists. Avoid the Solana SDK's `DEFAULT_MS_PER_SLOT` - this value is set when the SDK is compiled, so what was true on build day may not reflect the network a program is running on. -The gates give the length the network aims for, and the network can run slower than that. When slots take longer than the configured length, interest that accrues per slot earns less over a real year than its annual rate implies, and a 150-slot freshness window covers more wall-clock time than it was sized for, so it accepts older prices. +The gates give the length the network aims for, and the network can run slower than that. When slots take longer than the configured length, a 150-slot freshness window covers more wall-clock time than it was sized for, so it accepts older prices. -To measure the average the network is actually delivering, take the seconds since `epoch_start_timestamp` in the Clock sysvar and divide by the slots since this epoch began, which `EpochSchedule` supplies. Guard that division: the divisor is zero on an epoch's first slot. That measurement comes from the leader-written timestamp, so it belongs in offchain tooling, not in a handler. A program counts slots so that no leader can move its accrual, and multiplying a slot count by a measured slot length would let the timestamp back in. +To measure the average the network is actually delivering, take the seconds since `epoch_start_timestamp` in the Clock sysvar and divide by the slots since this epoch began, which `EpochSchedule` supplies. Guard that division: the divisor is zero on an epoch's first slot. That measurement comes from the leader-written timestamp, so it belongs in offchain tooling, not in a handler. A handler that needs wall-clock time should read the timestamp itself, as the lending market's interest does. Multiplying a slot count by a measured slot length carries the timestamp's error and adds the slot count's. > **One conversion covers one slot time.** Converting only works over a period where the slot time in milliseconds was constant. If the slot time changed during the period being measured, multiplying by the current figure understates the time that passed, since slots in the past were longer. Converting over a period where the slot time changed means splitting it at each change, which always falls on an epoch boundary, and converting each piece at the slot time the network used then. A slot count that reaches back across the reduction to 200 milliseconds needs that split, while one that starts after it does not. At the time of writing, no reduction below 200 milliseconds is planned. @@ -139,26 +141,25 @@ When validators cannot agree which block is real, block production halts instead A halt is rare: at the time of writing, block production has not stopped for years. A program should still handle one. -No block is produced during a halt, so the slot count does not move. Wall-clock time does. The first block after a restart carries a slot roughly where the last one left off and a timestamp hours later. That gap has three consequences: +No block is produced during a halt, so the slot count does not move. Wall-clock time does. The first block after a restart carries a slot roughly where the last one left off. Its timestamp cannot jump the hours the halt took either, because the network rejects a block whose time runs further ahead than the slots since its parent allow. The timestamp trails real time after a restart and catches up over the blocks that follow. That gap has three consequences: -- **Per-slot accrual pauses with the chain.** The lending market's factor advances by elapsed slots, so a nine-hour halt accrues no interest: nobody is charged for a market they could not reach. A fee accruing by timestamp charges the full nine hours. -- **A window counted in seconds refuses the old price. One counted in slots accepts it, which is the dangerous half.** The vault strategy refuses a price older than sixty seconds, so its first call after a halt fails until a publisher posts again. The lending market's 25-slot window sees a price stamped one slot ago and, on the slot count alone, would accept a number nine hours old. The damage is largest on the perpetual futures venue, where leverage multiplies a bad price across every open position. -- **A deadline can pass with nobody able to act on it.** The fundraiser's day count runs through a halt like any other nine hours, so a contributor with an hour left finds the window shut when the chain returns. That program survives it because its refund path has no deadline; a design whose only exit was a narrow window would have stranded them. +- **Accrual by timestamp charges the halt, a little late.** The lending market's interest, the perpetual futures venue's funding, and the vault's fee all count seconds, so a nine-hour halt accrues nine hours. It arrives over the catch-up rather than in the first block. The loans were outstanding and the suppliers' money was locked for those nine hours, so charging for them is accurate. +- **Neither kind of freshness window refuses the old price on its own.** The lending market's 25-slot window sees a price stamped one slot ago and would accept a number nine hours old. The vault's sixty-second window does no better, because the timestamp it reads is trailing, so a price published just before the halt looks seconds old. The damage is largest on the perpetual futures venue, where leverage multiplies a bad price across every open position. +- **A deadline can pass with nobody able to act on it.** The fundraiser's day count runs through a halt like any other nine hours. A contributor with an hour left before the halt gets less than an hour after it, because the timestamp runs fast while it catches up. That program survives it because its refund path has no deadline; a design whose only exit was a narrow window would have stranded them. -A slot-counted window needs one more check. The `LastRestartSlot` sysvar[^last-restart-slot] carries the slot of the most recent cluster restart, or zero if there has never been one, and a price from before that restart is stale however few slots have passed since. The lending market, the prop AMM, and the perpetual futures venue all make this check in their oracle readers: any price stamped at or before the restart slot is rejected with `PricePredatesRestart`, and the market pauses valuation until the publisher posts again. Make it in any program whose freshness window is counted in slots. +Every freshness window needs one more check. The `LastRestartSlot` sysvar[^last-restart-slot] carries the slot of the most recent cluster restart, or zero if there has never been one, and a price from before that restart is stale however few slots have passed since. The lending market, the prop AMM, the perpetual futures venue, and the vault strategy all make this check in their oracle readers: any price stamped at or before the restart slot is rejected with `PricePredatesRestart`, and the market pauses valuation until the publisher posts again. Make it in any program that checks a price's age, in slots or in seconds. The order book comes through a halt untouched, because it reads neither clock: queue positions resume exactly as they were. -## A Shorter Slot Tightens Every Window and Speeds Up Every Rate +## A Shorter Slot Tightens Every Window Counted in Slots -The network has shortened the slot before, most recently in stages from 400 milliseconds to 200.[^slot-times] The change is rare and happens in one step: the number holds for years, then moves once. A program that planned for it keeps charging what its owner intended; one that did not needs a redeploy. +The network has shortened the slot before, most recently in stages from 400 milliseconds to 200.[^slot-times] The change is rare and happens in one step: the number holds for years, then moves once. -A shorter slot pulls the two uses of a slot count in opposite directions: +A freshness window sized in slots gets stricter. A 150-slot window covers less wall-clock time than it did, so it refuses prices it used to accept. That costs availability and never safety, and it announces itself the first time a price is refused. The defense is to keep the window where a transaction can reach it, so the change needs a configuration transaction rather than a redeploy. -- **A freshness window sized in slots gets stricter.** A 150-slot window covers less wall-clock time than it did, so it refuses prices it used to accept. That costs availability and never safety, and it announces itself the first time a price is refused. -- **A rate expressed per slot accrues faster, and gives no warning.** An annual rate divided by a fixed slots-per-year number assumes a slot duration, so halving the slot doubles what a borrower pays in a day while every line of the program stays as it was. +A rate counted in seconds is untouched. The lending market's interest and the perpetual futures venue's funding charge the same per hour at any slot length, with nothing to retune. A rate counted in slots gives no such warning when the slot shortens. An annual rate divided by a fixed slots-per-year number assumes a slot length, so halving the slot doubles what a borrower pays in a day while every line of the program stays as it was. -The defense is the same for both: keep the number where a transaction can reach it. The lending market holds its divisor as `slots_per_year`, a config value its owner can retune for this reason. The perpetual futures venue's `set_funding_rate()` does the same job for a per-slot rate, advancing the index at the old rate before storing the new one, so the slots already elapsed are charged at the rate that applied to them. A change to the slot time then needs a configuration transaction rather than a redeploy. +> **War Story: the SPL token-lending slot constant.** The SPL token-lending program, the ancestor of several Solana lending markets, turned annual rates into per-slot rates with a constant built from the SDK's tick rate: ticks per second, divided by ticks per slot, times the seconds in a year.[^spl-slots-per-year] The SDK then ran 160 ticks a second and 64 to a slot, 2.5 slots a second, but integer division threw the half away and compiled the constant for 2. At the 400-millisecond target every borrower paid a quarter more than the annual rate they were quoted. Save's fork wrote the result in as a number, with a comment calling it the slot rate. At 200 milliseconds the same constant charges two and a half times the quoted rate. ## What the Programs in This Book Chose @@ -167,8 +168,8 @@ The table lists every program in Part II that touches time, and the clock each o | Program | What it times | Its answer | Why | | --- | --- | --- | --- | | token fundraiser | its funding deadline | whole days from one stored timestamp | a week of slots is [PLACEHOLDER: measure hours of drift per week after 200 ms slots and Alpenglow go live] out | -| lending market | interest accrual, and price freshness | slots for both | seconds at the liquidation line are worth money | -| perpetual futures | funding accrual, and price freshness | slots for both | the same, and leverage multiplies it | +| lending market | interest accrual, and price freshness | timestamps for interest, slots for freshness | seconds against a year are nothing, and a stale price is the attack | +| perpetual futures | funding accrual, and price freshness | timestamps for funding, slots for freshness | the same, and leverage multiplies a stale price | | prop AMM | price freshness | slots | a stale price is the attack | | vault strategy | an annual fee, and price freshness | timestamps for both | seconds against a year are nothing, and the feed stamps seconds | | order book | which order came first | a sequence number, neither clock | neither clock is fine enough to order by | @@ -176,20 +177,22 @@ The table lists every program in Part II that touches time, and the clock each o ## What You Learnt - Solana has two clocks. The slot count only goes up and nobody can move it, though what it comes to in seconds varies a little and stops in a halt. The Unix timestamp is wall-clock time written by a leader, out by a few seconds somebody chose, however long the span. -- Count slots for price freshness, for accrual, and for anything an adversary running a leader could profit from by shifting the time a few seconds. Use the wall clock for a span of days or years quoted to a person, with a margin. Use a counter of your own for order of arrival, and whatever unit the publisher stamped for somebody else's number. -- To accrue interest or funding without touching every account, store each account's principal and one shared accumulation factor that advances by elapsed slots. A current balance is the principal multiplied by the factor. +- Count slots for price freshness and for anything an adversary running a leader could profit from by shifting the time a few seconds. Use the wall clock for a rate or a deadline quoted to a person, with a margin at any deadline. Use a counter of your own for order of arrival, and whatever unit the publisher stamped for somebody else's number. +- To accrue interest or funding without touching every account, store each account's principal and one shared accumulation factor that advances by elapsed seconds. A current balance is the principal multiplied by the factor. - A price needs a freshness window, each consumer sets its own, and the unit follows the source: slots where the program chooses, seconds where the feed published seconds. - A slot count converts to wall-clock time only at the slot length in force, and only across a span where that length held. Read that length from the slot-time feature gates or measure it against the epoch, and better still hold it as a figure you can update without a redeploy. Anza's documentation carries the current figure, and the companion repository the current way to get it. -- A shortened slot is rare and arrives all at once: every slot-counted window tightens, and every per-slot rate accrues faster without saying so. Keep both where a transaction can retune them. +- A shortened slot is rare and arrives all at once. Every slot-counted window tightens, so keep windows where a transaction can retune them. A rate counted in seconds is untouched, while one counted in slots accrues faster without saying so. - A deadline counted in whole days floors, so both sides of it must be computed from the same expression, and the window ends at the first tick of the last day. Say which edge you meant when you write the terms down. - For order of arrival, use a number of your own: the order book gives each resting order the next number from a counter it keeps, and keeps its timestamp for display only. -- Solana halts rather than run two conflicting histories. During a halt slots pause and wall-clock time keeps running, so per-slot accrual stops, deadlines expire with nobody able to act on them, and a price that is hours old still looks one slot old. -- Pair every slot-counted freshness window with a `LastRestartSlot` check, which rejects any price stamped at or before the last restart. +- Solana halts rather than run two conflicting histories. During a halt slots pause and wall-clock time keeps running, but the timestamp cannot jump, so it trails after a restart and catches up. Accrual by timestamp charges the halt during the catch-up, deadlines expire with nobody able to act on them, and a price that is hours old still looks fresh. +- Pair every freshness window, in slots or in seconds, with a `LastRestartSlot` check, which rejects any price stamped at or before the last restart. [^forks]: Two validators can briefly build on different parents, splitting the chain into **forks**, until the network settles on one. A block for a lower slot on one fork can even be produced after a block for a higher slot on another, but a program only ever sees one of the forks. [^alpenglow-clock]: The leader's time is carried in a footer at the end of its block. Source: Brennan Watt of Anza, in a message to the author in September 2026, and [github.com/anza-xyz/agave/issues/15441](https://github.com/anza-xyz/agave/issues/15441). +[^spl-slots-per-year]: The constant is `SLOTS_PER_YEAR` in the program's state module, at [github.com/solana-labs/solana-program-library/blob/master/token-lending/program/src/state/mod.rs](https://github.com/solana-labs/solana-program-library/blob/master/token-lending/program/src/state/mod.rs), and Save's copy is at [github.com/solendprotocol/solana-program-library/blob/master/token-lending/program/src/state/mod.rs](https://github.com/solendprotocol/solana-program-library/blob/master/token-lending/program/src/state/mod.rs). + [^slot-times]: The stages of that reduction are published at [solana.com/upgrades/reduced-slot-times](https://solana.com/upgrades/reduced-slot-times). [^anza-docs]: Anza maintains the validator client and its documentation, at [docs.anza.xyz](https://docs.anza.xyz).