Hindsight

API

The service's API lives at https://api-production-9003.up.railway.app. <APP_ORIGIN> below stands for your application's origin, which the service must list as an allowed origin. The API serves indexed chain data, wallet sessions, opaque encrypted envelopes and notifications. Transactions and proofs are built and signed by the client. The separate auditor API is for authorized operators.

Transport and common shapes

Requests and responses use JSON, except the notification stream. Send Content-Type: application/json for JSON writes. Browser calls require an allowed Origin; wallet-authenticated calls also require that origin to match the session. Use HTTPS and credentials: 'include'. The typed client handles cookies, response validation and CSRF tokens:

import { createApiClient } from '@hindsight/contract/client';

const api = createApiClient({
  apiOrigin: 'https://api-production-9003.up.railway.app',
  auditorApiOrigin: '<AUDITOR_API_ORIGIN>',
});
const runtime = await api.call('runtimeConfig', { params: {}, query: {}, body: {} });

Most successes have this envelope; each table below describes its data value:

{ data: T, meta: {
  requestId: string,
  indexedSlot?: U64, snapshotSlot?: U64, genesisHash?: Address,
  historyComplete?: boolean, csrfToken?: Hex32
} }

Address is a base58 32-byte Solana public key. Signature is a base58 transaction signature. U64 is a canonical unsigned decimal string, never a JSON number. Hex32 is 64 lowercase hex characters; other hex byte strings are lowercase too. Times use ISO 8601 UTC. A page is {items: T[], nextCursor: string|null}; paginated calls accept ?cursor=<opaque cursor>&limit=<1..100>. Pass returned cursors unchanged. Unlisted request fields are rejected.

Errors use an HTTP failure status and {error:{code,message,retryable,requestId,fieldErrors?,retryAfterSeconds?}}. Common codes are INVALID_INPUT (400), UNAUTHORIZED (401), FORBIDDEN (403), NOT_FOUND (404), CONFLICT or IDEMPOTENCY_CONFLICT (409), RATE_LIMITED (429) and UNAVAILABLE (503). Follow Retry-After when present. Refresh chain/account state before rebuilding after a conflict. Missing or pruned indexing history is explicit in each response.

Idempotency-Key is required on envelope uploads, access requests and payroll progress writes: 8–128 letters, digits, _ or -. Reuse it for the same request body after a lost response; use a new key for a new action. A successful replay returns Idempotency-Replayed: true; reusing a key with different content conflicts.

Read runtime configuration first

GET https://api-production-9003.up.railway.app/v1/runtime — public; typed call runtimeConfig.

{
  status: 'ok'|'waiting', cluster: 'LOCALNET'|'DEVNET'|'MAINNET_BETA',
  genesisHash: Address, programId: Address,
  rpcUrl: string, rpcRelay: boolean,
  rentRecovery?: {
    address: Address, status: 'not_initialized'|'active',
    closeNonce: U64|null, initialAccountSequence: U64|null
  },
  buyback?: {
    address: Address, status: 'not_configured'|'active',
    tokenMint: Address|null, tokenProgram: Address|null, keeper: Address|null,
    minimumOutputNumerator: U64|null, minimumOutputDenominator: U64|null,
    totalBurnedBaseUnits: string // canonical unsigned decimal u128
  },
  pools: [{
    pool: Pool & {principalBaseUnits: U64, protocolFeeAccount: Address, protocolFeeBalanceBaseUnits?: U64},
    mint: {address: Address, decimals: number},
    auditorKeyRecord: {address: Address, owner: Address, epoch: U64, encapsulationKeyHex: string}
  }],
  features: {walletSessions: true, relay: true, notifications: true, faucet?: true}
}

Use rpcUrl for browser RPC. When rpcRelay is true it is the API's /v1/rpc endpoint; the provider URL/key stays on the server. Localnet may return a direct RPC URL. Match the expected genesis and program before signing. status: 'waiting' and an empty pool list mean initialization has not completed; refresh later.

Pool = {
  address: Address, mint: Address|null,
  cluster: 'localnet'|'devnet'|'mainnet-beta', vault: Address, protocolFeeAccount?: Address, protocolFeeBalanceBaseUnits?: U64,
  auditorAttestationKey: Address, auditorKemEpoch: U64, policy: Policy,
  status: 'configuration_required'|'active'|'paused',
  configurationRequired: string[], indexedSlot: U64
}
Policy = {
  attestRequired: boolean|null, returnTimeoutSlots: U64|null,
  depositCapBaseUnits: U64|null, poolCapBaseUnits: U64|null,
  rampFeeBasisPoints: number|null, transferFeeBaseUnits: U64|null,
  auditorRotationIntervalSeconds: U64|null,
  adminMultisigThreshold: number|null, adminTimelockSeconds: U64|null
}

Use the mint's decimals to display caps, fees and principal. A null policy value is unknown configuration, not zero. A pool charges a ramp fee of 0 to 100 basis points on deposits and withdrawals (Hindsight's pools charge 5); the flat transfer fee is zero. Runtime configuration always includes pool.protocolFeeAccount: the pool mint's associated token account owned by the program's ['protocol'] PDA. The entire protocol ramp fee collects there. The only way tokens leave this fee account is an atomic buyback: the configured keeper claims an explicit amount, swaps it for the configured product token and burns all newly received output in the same transaction. See Public buyback state below for the configuration and on-chain burned total. faucet is omitted outside localnet. GET https://api-production-9003.up.railway.app/health returns raw {"status":"ok"} when ready or {"status":"waiting"} during program/pool initialization, both HTTP 200. The auditor service exposes the same health path on its own origin.

RPC relay

POST https://api-production-9003.up.railway.app/v1/rpc — public; typed call rpcRelay. It accepts one JSON-RPC request, not a batch:

{"jsonrpc":"2.0","id":1,"method":"getLatestBlockhash","params":[{"commitment":"confirmed"}]}

The response is raw JSON-RPC, without the API envelope: {"jsonrpc":"2.0","id":1,"result":...} or {"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"..."}}. Provider errors are replaced with a fixed safe message. Request validation and HTTP rate-limit failures use the common API error shape.

Allowed methods: getAccountInfo, getMultipleAccounts, getLatestBlockhash, getEpochInfo, getSignatureStatuses, getBalance, getTokenAccountBalance, getTokenAccountsByOwner, getGenesisHash, getSlot, sendTransaction, simulateTransaction. params is an optional array with at most ten elements; id is a string, safe integer or null. Other methods are rejected.

Identical reads share a short cache and in-flight request; commitment and minimum-context-slot options are part of the cache key. The default read cache is one second and the default per-client limit is 120 requests per minute. Writes/simulation are not cached. RPC diagnostics record method names only.

sendTransaction requires canonical base64 wire bytes, at most 4,096 bytes, and an options object with encoding: 'base64'. The transaction must contain a configured Hindsight program instruction. Other instruction programs are restricted to ComputeBudget, the Lighthouse wallet guard, the native ZK ElGamal proof program and Associated Token Account program; program IDs must resolve from static message accounts. This supports SDK proof instructions and idempotent withdrawal ATA creation. Sign the complete transaction, including wallet guard instructions, before submission.

Wallet sessions

  1. POST /v1/auth/challenge (authChallenge), body {wallet: Address, origin: '<APP_ORIGIN>'}. Returns {challengeId, message, expiresAt}. The body origin must equal the request's Origin header.
  2. Have the wallet sign the exact UTF-8 message; do not reconstruct or edit it.
  3. POST /v1/auth/verify (authVerify), body {challengeId, wallet, signatureHex}; the signature is 128 lowercase hex characters. Returns {session} and meta.csrfToken, and sets the HttpOnly wallet cookie. Challenges are single-use and bind the origin, API audience, cluster, program and genesis.
  4. GET /v1/auth/session (session) returns {session} and the CSRF token. Session = {id, wallet, role, poolScopes: Address[], expiresAt}.
  5. POST /v1/auth/logout (logout), empty body, returns {signedOut: true} and expires the cookie.

Wallet-authenticated writes require X-CSRF-Token from verification/session metadata as well as the cookie. The typed client retains this token per API domain. Session signatures only authorize API access; they are never encryption-key entropy. The session cookie belongs to the API's origin, so serve your application on the same site as the API for browsers that keep cookies first-party.

Pools, keys and accounts

All paths below are relative to https://api-production-9003.up.railway.app. “Wallet” means a signed wallet session with the matching Origin.

HTTP callAuthQuery / response data
GET /v1/pools (pools)PublicPagination → page of Pool
GET /v1/pools/{pool} (pool)Public`{pool: Pool
GET /v1/pools/{pool}/vault (vault)Public`{vault: Address, publicBalanceBaseUnits: U64
GET /v1/pools/{pool}/auditor-key (poolAuditorKey)Public`{keyRecord: KeyRecord
GET /v1/owners/{owner}/key (currentKey)Public`{keyRecord: KeyRecord
GET /v1/owners/{owner}/keys (keyHistory)PublicPagination → page of KeyRecord
GET /v1/owners/{owner}/key-snapshot (keySnapshot)PublicOptional epoch: U64 → `{keyRecord: KeySnapshot
GET /v1/pools/{pool}/onboarding (onboarding)Wallet`{state, currentKey: KeyRecord
GET /v1/pools/{pool}/accounts (accounts)WalletPagination → page of owned ConfAccount
GET /v1/accounts/{account} (account)Wallet, account owner`{account: ConfAccount
GET /v1/accounts/{account}/public-history (publicHistory)Wallet, account ownerPagination → page of public deposit/withdraw records
GET /v1/indexer/coverage (indexerCoverage)PublicCoverage status, actual genesis, finalized/snapshot slots, history completeness and explicit gap ranges
KeyRecord = {
  address: Address, owner: Address, epoch: U64, encapsulationKeyHex: string,
  registeredSlot: U64, status: 'current'|'outdated'
}
KeySnapshot = Omit<KeyRecord, 'registeredSlot'> & {
  registeredSlot: U64|null, transactionSignature: Signature|null, observedSlot: U64
}
ConfAccount = {
  address: Address, pool: Address, owner: Address,
  balanceCommitmentHex: Hex32, encryptedSelfRecordHex: string,
  sequence: U64, keyEpoch: U64, frozen: boolean, indexedSlot: U64
}
PublicRamp = {
  id: string, account: Address, kind: 'deposit'|'withdraw',
  amountBaseUnits: U64, feeBaseUnits: U64,
  signature: Signature, slot: U64, occurredAt: string, status: 'finalized'
}

X-Wing public keys contain 1,216 bytes (2,432 hex characters); encrypted self-records contain 68 bytes (136 hex characters). The client opens the self-record and checks its opening against the balance commitment. onboarding.state is no_key, key_registered, account_open or key_epoch_outdated. Registering a key and opening an account are wallet-signed program instructions, not HTTP writes. For pruned registration history, use keySnapshot and its nullable provenance rather than guessing a registration slot.

publicHistory returns a page of PublicRamp. Its amountBaseUnits is the public gross instruction amount a; feeBaseUnits is the actual protocol fee f = floor(a * rampFeeBasisPoints / 10_000). Deposit increases the confidential balance and pool principal by a - f. Withdrawal decreases both by a and pays a - f to the destination. The protocol receives all f, including on deposits; rounding stays with the user. At 5 basis points, an amount below 2,000 base units has zero fee. Transfers remain fee-free. Historical ramps from the former zero-fee instruction layout retain zero fees.

Notes and envelopes

HTTP callAuthQuery / response data
GET /v1/accounts/{account}/inbox (inbox)Wallet, account ownerPagination → page of Note
GET /v1/accounts/{account}/outbox (outbox)Wallet, account ownerPagination → page of Note
GET /v1/accounts/{account}/note-snapshots (noteSnapshots)Wallet, account ownerRequired direction=inbox|outbox, pagination → page of NoteSnapshot
GET /v1/notes/{note} (note)Wallet, Note participant`{note: Note
GET /v1/envelopes/{hash} (envelopes)Public`{envelopes: EnvelopePair
PUT /v1/envelopes/{hash} (relayEnvelopes)Wallet, sender; CSRF + idempotencyBody below → {stored: boolean, envelopeHashHex: Hex32}
Note = {
  address: Address, pool: Address, sender: Address, recipient: Address,
  originalRecipient: Address, senderSequence: U64,
  commitmentHex: Hex32, envelopeHashHex: Hex32,
  payeeKeyEpoch?: U64, auditorKemEpoch?: U64,
  createdSlot: U64, createdAt: string, returnEligibleSlot: U64|null,
  attested: boolean, state: 'awaiting_attestation'|'attested'|'accepted'|'returned',
  transactionSignature: Signature
}
NoteSnapshot = Omit<Note, 'createdAt'|'transactionSignature'> & {
  createdAt: string|null, transactionSignature: Signature|null,
  observedSlot: U64, historyComplete: boolean, onChainPresent: boolean
}
EnvelopePair = {
  note: Address, envelopeHashHex: Hex32,
  payeeEnvelopeHex: string, auditorEnvelopeHex: string, availableUntil: string|null
}

Upload body: {note, envelopeHashHex, payeeEnvelopeHex, auditorEnvelopeHex}. Each envelope is exactly 1,208 bytes (2,416 lowercase hex characters). {hash}, envelopeHashHex and SHA256(payeeEnvelopeBytes || auditorEnvelopeBytes) must agree. The authenticated wallet must be the Note sender. Uploads arriving before the Note is indexed can be held pending; successful storage does not mean the pair is already downloadable. Public downloads contain ciphertext only.

Before accepting, open the envelope locally and verify its commitment, original payee identity and recorded epochs against fresh chain state. Read the appropriate historical keySnapshot for old notes; do not replace an old epoch with the current key. A required auditor attestation must have landed. The client derives readiness and timeout actions from the opening check, pool policy and current chain slot. A missing account or incomplete snapshot does not establish acceptance.

Transaction status and notifications

GET /v1/transactions/{signature} (transaction) requires a wallet session and authorizes the wallet's associated transaction. Returns {signature, state, slot: U64|null, failureCode: string|null, indexed: boolean}. State is submitted, confirmed, finalized, failed, expired or unknown; failure codes are TRANSACTION_REJECTED, BLOCKHASH_EXPIRED or ACCOUNT_CHANGED. Unknown/pruned status is not proof that submission failed. Reconcile the exact transaction and fresh account state before retrying a payment.

GET /v1/notifications?cursor=...&limit=... (notifications) requires a wallet session and returns a page of {id, kind, note, pool, slot, occurredAt}. Kinds are new_note, attested, accepted and returned.

GET /v1/events?cursor=... (notificationEvents) is the authenticated SSE transport. It accepts Last-Event-ID for reconnects. Use credentialed fetch streaming or api.subscribeNotifications(...), keep the last notification ID and reconnect/poll after disconnection. Notification frames have event: notification, id: <cursor> and JSON notification data; heartbeat frames keep the stream alive. An event: error contains {error:{code,message,retryable,requestId}}; the typed client also classifies its HTTP-style status. Abort the stream when the view closes and sign in again after UNAUTHORIZED.

Access requests

POST /v1/access-requests (createAccessRequest) is public and requires Idempotency-Key. A fresh request returns HTTP 201 with {receipt:{id, status:'received', createdAt}}; personal fields are not echoed.

{
  name: string,                 // required, 1..120 characters
  workEmail: string,            // required email, at most 254 characters
  organization: string,         // required, 1..160 characters
  workflow: 'employer_payroll'|'payroll_platform'|'treasury'|'stablecoin_issuer'|'other',
  monthlyPaymentRange?: 'under_10'|'10_to_49'|'50_to_249'|'250_to_999'|'1000_or_more',
  message?: string,             // at most 2,000 characters
  website?: ''                  // honeypot: leave empty
}

The range describes monthly payment counts, not amounts. Validation errors expose fixed field paths/codes, for example {path:'body.workEmail', code:'invalid_email'}. Requests are stored by the deployed service; this call does not send email or initiate a payment.

Client payment sequence

Read runtime config and authenticate; use the SDK to register a key/open an account when needed. Refresh raw RPC account state before each balance-changing transaction. Build deposits, transfers, attest-dependent accepts, returns and withdrawals with the SDK transaction builders, generate proofs locally, sign with the wallet, then submit the signed wire through the configured RPC endpoint. Transfer envelope pairs can be uploaded with relayEnvelopes; payees fetch/open/check them before acceptance. Open encryptedSelfRecordHex locally for the balance. HTTP indexing and notifications provide observations; finalized chain state remains authoritative.

Plaintext openings, confidential amounts/memos, self-record keys, private KEM keys and payroll CSV stay in the client. The relay accepts encrypted bytes; the RPC relay accepts signed on-chain transactions. Optional public payroll progress (GET /v1/payroll-plans, GET/PUT /v1/payroll-plans/{id}) stores only the local plan digest, ordered Note references and chain-derived counts; use the contract's payrollProgressSchema for its shape.

Public buyback state (0.15.0)

Read runtimeConfig.buyback (also available on the local compatibility runtime call). not_configured has null mint/program/keeper/rate and a zero total; it is an intended pre-launch state, not a failed pool. Once set, active exposes the immutable mint, its token program, keeper, fixed minimum output rate and the actual on-chain total burned. Each runtime pool includes its canonical fee account and current public balance. An empty account has balance zero; RPC/config decoding failures are errors rather than invented totals.

All quantities use base units. The fixed floor requires at least ceil(claim * numerator / denominator) output units, with a potentially stricter quote minimum per claim. The total is updated only when final settlement has burned all newly received output; submitted quotes or signatures do not count as burns. Configuration and crank instructions are on-chain SDK calls; no new HTTP custody or withdrawal endpoint is added. The operator crank uses its private RPC configuration directly rather than the browser payment relay.

Closing and reopening user accounts (0.15.0)

Closing is a wallet-signed on-chain operation, not a hosted API custody endpoint. Read runtimeConfig.rentRecovery before opening or closing: its nonce binds a close proof and determines the new account's initial sequence (closeNonce << 32). An uninitialized registry requires the operator's upgrade-authority bootstrap; it is not a participant rent charge. After withdrawing the full confidential balance, use the SDK's zero-balance proof and close_account builder. The proof reveals neither the current blinding nor any historical amount. Its transcript binds the program, pool, account, owner, current sequence and rent-recovery nonce; frozen accounts cannot close. Rent goes directly to the owner wallet. close_key_record also refunds the owner and refuses a key currently reserved for a pool's auditor.

Current indexed reads follow the finalized account snapshot. A closed ConfAccount is omitted from accounts and onboarding state and returns NOT_FOUND from account; currentKey, keySnapshot and keyHistory omit closed KeyRecords. Previously indexed Notes, ramps and events remain historical records. The API does not keep advertising a stale account or key as usable after closure. RPC/account snapshots remain authoritative while a transaction awaits finality.

An owner may register a closed key again (the same epoch or another epoch), then open_account at the same fixed ConfAccount PDA. The reopened account has a fresh zero balance/self-record and a new sequence namespace. Open Notes are not destroyed by closing the account: reopen before accepting a late payment or returned Note. Retain the private note openings, KEM keys and self-record key needed for recovery. Closing a public KeyRecord never deletes copies of those public bytes already present in chain history or an indexer's historical records.