Technical specification · revision 2

ComputeLock v1: spec

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 to u64/i64 is 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_type as one byte, offer_id as 8 bytes little-endian. packages/client exports 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, where period_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_side equals exactly periods × 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) at take_offer, on top of its collateral.
  • The provider pre-funds fee_reserved = fee(gpu_hours_total) at create_offer. At each take the provider fee is fee_reserved if the take empties the offer, otherwise floor(fee_reserved × g / gpu_hours_open); then fee_reserved -= provider_fee. cancel_offer returns fee_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
  • buyer and provider come first so they work as getProgramAccounts memcmp keys right after the 8-byte discriminator: Position.buyer at offset 8, Position.provider at offset 40 [R15].
  • Config is immutable after init except through set_oracle_authority [R28].
  • Offer accounts are never closed in v1, so offer_id is single-use [R24].
  • Position accounting is internal [R3]. Settlement only moves numbers between buyer_balance and provider_balance. Tokens leave the position vault only in withdraw (each side's balance) and close_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_mint and the expected authority.
  • Payout recipients (withdraw recipient ATA, cancel_offer provider 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: by withdraw and close_position (the crank pays rent), cancel_offer (the provider pays) and take_offer (treasury; the buyer pays). The rent destination position.buyer is 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, which init_config requires to be the canonical ATA(treasury.owner, usdc_mint); that wallet is stored as config.treasury_owner, and every treasury_owner input is pinned to it (address = config.treasury_owner), so anyone can recreate a closed treasury. cancel_offer/close_position touch 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 own gpu_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_offer pays ~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 own create_idempotent or let the counterparty crank,
  • a recipient ATA frozen by the USDC freeze authority blocks that side's withdraw (and therefore close_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.
  • price is integer micro-USD; ts is 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]

  1. Take the latest valid attestation per provider, then compute the median M of all of them.
  2. Drop prices with |p − M| > 25% × M, then recompute the median.
  3. Publish only if ≥ 3 remain.
  4. 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 Crop49jpc7prcgAcS82WbWyGHwbN5GgDym3uFbxxCTZg and D9Pv7LthkwSYVJo5iGUPsAiosGwARj7eHTntuWFR1MK2 (owner nosJhNRqr2bc9g1nfGDcXXTXvYUmxD4cVwy2pMWhrYM), 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_index on 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's solLamports is 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, with simulated: true

Extensions (T7a):

  • history points may carry source: "backfill": reconstructed h100-depin history from before the first live snapshot (Nosana job_price in force at the day's close × DexPaprika NOS/USD daily close; method and evidence in docs/gpu-price-sources.md). Points without source are live snapshots. Backfilled points are never simulated, never served by latest, and only ever precede live points.
  • GET /v1/status → what the deployment really does: publisher (offchain until the T2 program is deployed; snapshots then have publishedTx: null), minter (mock: the faucet enforces its limits but mints nothing; faucet responses carry mock: 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 in apps/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) when NEXT_PUBLIC_PRIVY_APP_ID is set. Otherwise the app falls back to Solana wallet-standard wallets.
Data driven by dexpaprika.comDexPaprikaNOS/USD for the H100 DePIN · Nosana sub-index