This is the single source of truth for T2 (program), T3 (indexer), T5 (client) and T6 (web). If code and spec disagree, the spec wins; change the spec in a PR first.
Revision 2 incorporates the evaluator review (docs/contract-review.md); item numbers from the review are cited as [R#].
1. Units and conventions
| Quantity | Type | Unit | Example |
|---|---|---|---|
| Price | u64 |
micro-USD per GPU-hour (1e-6 USD) | $2.71 → 2_710_000 |
| GPU-hours | u64 |
whole GPU-hours per period | 2_000 |
| Money | u64 |
micro-USDC (USDC has 6 decimals) | 65.04 USDC → 65_040_000 |
| Basis points | u16 |
1/10 000 | 30% → 3000 |
| Time | i64 |
unix seconds; on-chain now = Clock::unix_timestamp; off-chain uses cluster time (getBlockTime of a recent slot), never Date.now() [R30] |
- No floating point anywhere. Intermediate math uses
u128/i128; converting back tou64/i64is checked, and overflow →MathOverflow. - Time arithmetic is checked too [R9].
- Seeds are raw bytes [R14]: string literals as UTF-8, pubkeys as 32 bytes,
gpu_typeas one byte,offer_idas 8 bytes little-endian.packages/clientexports the PDA helpers; T3 and T6 must use them. - Golden fixture:
spec/pda-vectors.json, created in T2 once the program ID is fixed.
Boundaries [R30]
- Fresh:
feed.ts > 0 && feed.price > 0 && now - feed.ts <= max_staleness_secs(inclusive). - In window for period k (1-based):
period_end_k <= now <= period_end_k + settle_window_secs, whereperiod_end_k = start_ts + k·period_secs. - Expired window:
now > period_end_k + settle_window_secs.
2. Markets
gpu_type |
Market | Tradable in v1 | Index source |
|---|---|---|---|
| 0 | H100 |
yes | contributed index (§6.1) |
| 1 | H100_DEPIN |
no (display only) | Nosana on-chain (§6.2) |
| 255 | SIM |
no | localnet / simulation only, never on devnet [R22] |
3. Payoff math (canonical)
cap_per_hour = floor(fixed * cap_bps / 10_000)
diff = clamp(avg - fixed, -cap_per_hour, +cap_per_hour) // i128
settlement = diff * gpu_hours // signed micro-USDC
max_loss_per_side = cap_per_hour * gpu_hours * periods // MUST use the floored cap_per_hour, never fixed*gpu_hours*periods*cap_bps/1e4 [R51]
notional = fixed * gpu_hours * periods
fee(g) = floor(fixed * g * periods * fee_bps / 10_000)
settlement > 0: the provider owes the buyer.settlement < 0: the buyer owes the provider.- Because
max_loss_per_sideequals exactlyperiods × cap_per_hour × gpu_hours, worst-case settlements never exceed a side's deposit. Liquidations do not exist. - Defaults:
cap_bps = 3000,fee_bps = 40.
Fees [R4, R31]
- The buyer pays
fee(gpu_hours_taken)attake_offer, on top of its collateral. - The provider pre-funds
fee_reserved = fee(gpu_hours_total)atcreate_offer. At each take the provider fee isfee_reservedif the take empties the offer, otherwisefloor(fee_reserved × g / gpu_hours_open); thenfee_reserved -= provider_fee.cancel_offerreturnsfee_reserved. - With this rule the offer vault is exactly 0 after the final take or cancel.
- All fees go to
config.treasury.
Test vectors live in spec/vectors.json (payoff) and spec/twap-vectors.json (TWAP). Rust and TS both run them.
4. Accounts (T2)
| Account | Seeds | Fields (Borsh order) | Rent payer |
|---|---|---|---|
Config |
["config"] |
admin, oracle_authority, usdc_mint, treasury, treasury_owner, fee_bps: u16, cap_bps: u16, max_staleness_secs: i64, settle_window_secs: i64, bump |
admin |
IndexFeed |
["feed", [gpu_type]] |
gpu_type: u8, price: u64, ts: i64, cumulative: u128, bump |
admin |
Offer |
["offer", provider, offer_id_le] |
provider, offer_id: u64, gpu_type, fixed_price, gpu_hours_total, gpu_hours_open, periods: u8, period_secs: i64, cap_bps, fee_bps, fee_reserved: u64, created_ts, expires_ts, bump |
provider |
| offer vault | ATA(owner = Offer PDA, usdc_mint) |
open collateral + fee_reserved |
provider |
Position |
["position", offer, buyer] |
buyer, provider, offer, gpu_type, fixed_price, gpu_hours, periods, periods_settled: u8, period_secs, cap_bps, start_ts, cum_start: u128, ts_start: i64, buyer_balance: u64, provider_balance: u64, bump |
buyer |
| position vault | ATA(owner = Position PDA, usdc_mint) |
both sides' collateral | buyer |
buyerandprovidercome first so they work asgetProgramAccountsmemcmp keys right after the 8-byte discriminator:Position.buyerat offset 8,Position.providerat offset 40 [R15].Configis immutable after init except throughset_oracle_authority[R28].Offeraccounts are never closed in v1, sooffer_idis single-use [R24].- Position accounting is internal [R3]. Settlement only moves numbers between
buyer_balanceandprovider_balance. Tokens leave the position vault only inwithdraw(each side's balance) andclose_position(donation sweep). - Invariant until close:
buyer_balance + provider_balance == 2 × max_loss(gpu_hours)(tokens of that position).
5. Instructions (T2)
Common constraints
- Every token account a party pays from checks
mint == config.usdc_mintand the expected authority. - Payout recipients (
withdrawrecipient ATA,cancel_offerprovider ATA, treasury) are bound by address only:ATA(party, config.usdc_mint)resp.config.treasury. Their current token owner is never checked, so a party reassigning its ATA owner (SetAuthority AccountOwner) cannot block a counterparty; the Token program still enforces the mint on every transfer. A recipient ATA that was closed (or whose address only holds lamports) is recreated for the right wallet and mint when an amount > 0 is due: bywithdrawandclose_position(the crank pays rent),cancel_offer(the provider pays) andtake_offer(treasury; the buyer pays). The rent destinationposition.buyeris bound by address only (no system-owner check) and is credited exactly once, directly, by the position close (the vault is closed into the position first), so assigning the wallet away or deploying a program at its address cannot block close. - Per-side payouts (isolation): each side is paid by its own
withdraw(side). A recipient ATA that cannot receive (frozen by the mint's freeze authority) only blocks that side's own withdrawal; the other side and its funds are unaffected. The position closes only once both balances are 0. - The treasury must equal
config.treasury, whichinit_configrequires to be the canonicalATA(treasury.owner, usdc_mint); that wallet is stored asconfig.treasury_owner, and everytreasury_ownerinput is pinned to it (address = config.treasury_owner), so anyone can recreate a closed treasury.cancel_offer/close_positiontouch the treasury only when there is something to sweep. - Classic SPL Token program only.
- Feeds must be the PDA
["feed", [gpu_type]]of the offer or position's owngpu_type[R6, R12].
| Instruction | Signer | Checks and effects |
|---|---|---|
init_config(oracle_authority, fee_bps, cap_bps, max_staleness_secs, settle_window_secs); accounts: usdc_mint, treasury |
program upgrade authority (ProgramData check) [R13] | 1 ≤ fee_bps ≤ 500 (0 is rejected: create_offer requires fee(1) ≥ 1, so no offer could ever exist); 100 ≤ cap_bps ≤ 5000; 60 ≤ max_staleness_secs ≤ 86_400; 30 ≤ settle_window_secs ≤ 86_400; mint owned by classic Token with decimals == 6; treasury.mint == usdc_mint [R8, R12]; treasury == ATA(treasury.owner, usdc_mint) else InvalidParam |
set_oracle_authority(new) |
admin | rotates the oracle key |
init_feed(gpu_type) |
admin | price = 0, ts = 0, cumulative = 0 |
update_index(gpu_type, price) |
oracle_authority |
1 ≤ price ≤ 10^12; now ≥ old_ts else InvalidParam; if old_ts != 0: old/3 ≤ price ≤ old×3 [R18]. The 3× bound is per update, not per unit of time: k consecutive updates can move the price by up to 3^k, so it bounds a single bad publish, not a compromised oracle; dt = max(now − old_ts, 0); cumulative += old_price × dt (skipped when old_ts == 0); set price, ts = now. The price is a step function carried forward through outages |
create_offer(offer_id, gpu_type, fixed_price, gpu_hours, periods, period_secs, expires_ts) |
provider | gpu_type == 0 else MarketNotTradable; 1 ≤ periods ≤ 6; max(60, 4·settle_window_secs) ≤ period_secs ≤ 2_678_400 [R1, R9]; cap_per_hour ≥ 1 and fee(1) ≥ 1 [R10]; now < expires_ts ≤ now + 7 days [R16]; snapshot cap_bps, fee_bps; transfer max_loss(gpu_hours) + fee_reserved from the provider into the offer vault |
cancel_offer() |
provider (Unauthorized otherwise) |
return max_loss(gpu_hours_open) + fee_reserved to the provider's ATA; sweep any other vault balance (donations) to the treasury [R5]; gpu_hours_open = 0, fee_reserved = 0. Afterwards the offer vault is 0 |
take_offer(gpu_hours) |
buyer | buyer != provider else SelfTrade [R11]; now ≤ expires_ts else OfferExpired; 0 < gpu_hours ≤ gpu_hours_open else InsufficientOpenHours; feed fresh else StaleIndex; position must not exist (PositionExists) [R25]. The buyer transfers max_loss(g) + fee(g): collateral to the position vault, fee to the treasury. The provider's max_loss(g) moves offer vault → position vault and the provider fee moves offer vault → treasury (§3). Set buyer_balance = provider_balance = max_loss(g), start_ts = ts_start = now, cum_start = feed.cumulative + feed.price·(now − feed.ts) |
settle_period() |
anyone (crank) | periods_settled < periods else AllPeriodsSettled; now ≥ period_end_k else PeriodNotEnded; now > ts_start else PeriodNotEnded [R1]. In window: feed fresh else StaleIndex [R17]; cum_now = feed.cumulative + feed.price·(now − feed.ts); avg = floor((cum_now − cum_start)/(now − ts_start)); apply settlement by moving ` |
withdraw(side) (side: Buyer | Provider) |
anyone (crank) | periods_settled == periods else NotFullySettled; recipient == position.buyer resp. position.provider else Unauthorized; recipient ATA bound by address; pay that side's balance → ATA(recipient) (recreated if missing), set it to 0; a second call pays 0. Emits BalanceWithdrawn |
close_position() |
anyone | periods_settled == periods else NotFullySettled; buyer_balance == provider_balance == 0 else BalancesNotWithdrawn; sweep the whole remaining vault balance (donations) → treasury [R5]; close the vault into the position, then close the position with all rent → position.buyer [R26]. Clients send each withdraw as its own transaction, then close_position: a bundle of withdraw(Buyer) + withdraw(Provider) + close fails as a whole if either ATA cannot receive (e.g. frozen), which would also block the other side. A client may try the bundle first and must fall back to separate transactions when it fails |
TWAP window [R2]: the averaging window for period k is [ts_start, now_of_crank], not aligned to period boundaries. Drift is bounded by settle_window_secs, which is at most period_secs / 4. UI and indexer show expected payouts as estimates and the realised value from the PeriodSettled event. The T7 crank worker settles every due position within 120 s of period_end [R17].
Errors: Unauthorized, InvalidParam, StaleIndex, MarketNotTradable, InsufficientOpenHours, PeriodNotEnded, AllPeriodsSettled, NotFullySettled, MathOverflow, SelfTrade, OfferExpired, PositionExists, InvalidMint, BalancesNotWithdrawn.
Events (emit!) [R15]:
OfferCreated{offer, provider, gpu_type, fixed_price, gpu_hours, periods, period_secs, cap_bps, fee_bps, expires_ts}OfferTaken{offer, position, buyer, gpu_hours, fee_buyer, fee_provider}OfferCancelled{offer, returned, swept}PeriodSettled{position, period, avg, settlement: i64, zero_payout: bool, now}BalanceWithdrawn{position, side: u8 (0 buyer, 1 provider), recipient, amount}PositionClosed{position, swept}IndexUpdated{gpu_type, price, ts}
T2 commits idl/computelock.json in its PR [X2].
Known v1 limitations (stated in the README and the pitch):
- single oracle key,
- crank-chosen TWAP window within a bounded drift,
- provider attesters may be counterparties,
- Sybil wash trading is not prevented,
- a period falls back to zero payout if nobody cranks within the window,
- crank rent griefing: a party (or the treasury owner) can close its own ATA so that whoever cranks
withdraw/close_position/cancel_offerpays ~0.002 SOL to recreate it; the recreated ATA belongs to that party, and the party recovers the rent by closing it again. The cost is bounded (one ATA per recipient per instruction) and a crank can always prepend its owncreate_idempotentor let the counterparty crank, - a recipient ATA frozen by the USDC freeze authority blocks that side's
withdraw(and thereforeclose_position's rent refund) until it is thawed; the other side withdraws normally.
6. Index (T3)
6.1 Contributed index H100 (gpu_type 0)
Attestation format [R20]
The signed message is the UTF-8 bytes of:
computelock-attestation-v1\n{"gpu_type":0,"price":<int>,"provider":"<base58>","ts":<int>}
- Keys sorted, no whitespace.
priceis integer micro-USD;tsis unix seconds.- Signature: ed25519 by the provider key.
Validity: the provider is in the services/indexer/providers.json allowlist, now − 1800 ≤ ts ≤ now + 60, ts is strictly greater than the stored latest for that provider, and 1 ≤ price ≤ 10^12.
Aggregation [R19, R21]
- Take the latest valid attestation per provider, then compute the median M of all of them.
- Drop prices with
|p − M| > 25% × M, then recompute the median. - Publish only if ≥ 3 remain.
- Even count:
floor((a+b)/2).
On devnet the providers are seeded demo keys, labelled "demo". Seeded demo providers do not hold open offers.
6.2 DePIN sub-index H100_DEPIN (gpu_type 1)
- Decode the Nosana market accounts
Crop49jpc7prcgAcS82WbWyGHwbN5GgDym3uFbxxCTZgandD9Pv7LthkwSYVJo5iGUPsAiosGwARj7eHTntuWFR1MK2(ownernosJhNRqr2bc9g1nfGDcXXTXvYUmxD4cVwy2pMWhrYM), backed by a golden account dump fixture [R44]. - Convert with a NOS/USD source whose terms allow it. T7a: DexPaprika (Pro API, all NOS pools) is the primary NOS/USD; the Raydium CLMM NOS/USDC pool spot is a sanity check, and if the two differ by more than 5% of the DexPaprika price the index is
unavailable(services/indexer/src/depin.ts). - Otherwise
status: "unavailable". Never a fallback number.
6.3 Publishing, simulation, faucet
- Publishing:
update_indexon devnet every 15 min (Worker cron in T7; CLI loop locally). Recommended devnet config:max_staleness_secs = 2700,settle_window_secs = 900; localnet tests use small values. - Simulation: runs only on localnet or with
gpu_type = 255. It never writes to devnet gpu_type 0 or into history [R22]. - Faucet (T3 owns it; deployed in T7) [R23]:
POST /v1/faucet {wallet}mints 10 000 mock USDC per wallet per 24 h. The same transaction also sends 0.02 devnet SOL from the mint authority for fees when the wallet holds < 0.01 SOL and the authority stays ≥ 0.5 SOL after it; the response'ssolLamportsis the amount sent ("0" when skipped) (#46). The mint authority exists only as a Worker secret. Localnet tests mint directly.
6.4 HTTP API [R21]
{market} ∈ h100 | h100-depin. Prices are integer micro-USD JSON numbers; ts is unix seconds.
GET /v1/index/{market}/latest→{ market, status: "ok"|"unavailable"|"stale", price, ts, sources: [{ id, label, demo: bool, price, ts, kind: "attestation"|"onchain" }], publishedTx }GET /v1/index/{market}/history?days=N→{ market, simulated: false, points: [{ ts, price }] }(own snapshots only)GET /v1/index/{market}/simulation→ same shape, withsimulated: true
Extensions (T7a):
historypoints may carrysource: "backfill": reconstructedh100-depinhistory from before the first live snapshot (Nosanajob_pricein force at the day's close × DexPaprika NOS/USD daily close; method and evidence indocs/gpu-price-sources.md). Points withoutsourceare live snapshots. Backfilled points are neversimulated, never served bylatest, and only ever precede live points.GET /v1/status→ what the deployment really does:publisher(offchainuntil the T2 program is deployed; snapshots then havepublishedTx: null),minter(mock: the faucet enforces its limits but mints nothing; faucet responses carrymock: true), the clock source and the cron.
Internal sanity check only: a Bitget indexPrice deviation above 5% logs a warning. It is never published or stored.
7. Client (T5) and web (T6)
@computelock/client must export [R27]:
capPerHour,settlement,maxLossPerSide,notional,feePerSide(done)fee(fixed, g, periods, feeBps)requiredDeposit(fixed, g, periods, capBps, feeBps) → { maxLoss, fee, total }providerFeeForTake(feeReserved, g, gpuHoursOpen)twap(cumStart, cumNow, tsStart, now)isFresh(feedPrice, feedTs, now, maxStaleness)payoffCurve(fixed, g, periods, capBps, movesBps[])- PDA helpers
findConfigPda,findFeedPda,findOfferPda,findPositionPda(T5, once the program ID exists)
Web
- Screens follow
apps/web/design/plus the deviations inapps/web/design/README.md. - All money and payoff numbers come from
@computelock/client. - Auth: Phantom Portal is not accepting new apps (checked 2026-10-02), so we use Privy (
@privy-io/react-auth, Solana embedded wallets, Google login, devnet) whenNEXT_PUBLIC_PRIVY_APP_IDis set. Otherwise the app falls back to Solana wallet-standard wallets.