Sealroom
  1. Docs
  2. TypeScript client

TypeScript client

@sealroom/contract is the TypeScript client for Sealroom. It reads the registry, requests transaction previews, builds x402 payments and exports the schemas and types of every record. The Sealroom app is built on it. It never signs or sends anything by itself: your wallet signs, and you decide where the transaction goes.

Install

npm install @sealroom/contract

The package is ESM and runs in Node 22 or later and in browser bundles.

ImportContents
@sealroom/contractcreateSealroomClient, record types and schemas, wallet intents, x402 schemas
@sealroom/contract/solanax402 payment builders, PDA helpers, distribution preparation
@sealroom/contract/idlThe sealroom program's Anchor IDL as JSON

Create a client

import { createSealroomClient } from '@sealroom/contract'

const api = createSealroomClient({ baseUrl: '<api-url>' })
OptionMeaning
baseUrlThe API's base URL
timeoutMsPer-request timeout, covering connection, headers and body. Defaults to 10,000
fetchA custom fetch implementation

Methods

MethodRouteReturns
health()GET /healthService status
parameters()GET /v1/parametersProtocol parameters
summary()GET /v1/summaryRegistry totals
list(resource, query?)GET /v1/{resource}A page: items, nextCursor, total
detail(resource, id)GET /v1/{resource}/{id}One record
eligibility(modelId, nodeId)GET /v1/eligibilityRule-by-rule eligibility
previewTransaction(intent)POST /v1/transactions/previewAn unsigned transaction or its blockers
apply(input, idempotencyKey)POST /v1/applicationsAn application receipt

Every method accepts a final { signal } option to cancel the request with an AbortSignal.

Results

Methods never throw. Each returns an ApiResult<T>:

type ApiResult<T> =
  | { ok: true; data: T; meta: ResponseMeta }
  | { ok: false; error: ApiError; meta: ResponseMeta }
const result = await api.detail('models', modelId)
if (!result.ok) {
  console.error(result.error.code, result.error.message)
  if (result.error.retryable) { /* try again later */ }
  return
}
const model = result.data

Every response is validated against the contract's schemas before you see it. error.code is one of the API's codes (Public API) or, for failures before a valid response, NETWORK_ERROR, TIMEOUT, CANCELLED or INVALID_RESPONSE. meta.index.status tells you whether the read is fresh.

Read the registry

import type { Lane, ListQuery } from '@sealroom/contract'

// One page
const page = await api.list('models', { owner: ownerAddress, limit: 50 })

// Every page
async function allLanes(query: ListQuery) {
  const out: Lane[] = []
  let cursor: string | undefined
  do {
    const r = await api.list('lanes', { ...query, limit: 100, cursor })
    if (!r.ok) throw new Error(r.error.message)
    out.push(...r.data.items)
    cursor = r.data.nextCursor ?? undefined
  } while (cursor)
  return out
}

const lanes = await allLanes({ operatorId })

Resources: operators, platforms, nodes, attestations, runtimes, models, lanes, distributions, disputes, key-releases, activity. The query accepts limit, cursor, q and the resource's filters, listed on the Public API page.

The record types are exported by name (Operator, Platform, Node, Attestation, Runtime, Model, Lane, Dispute) and by resource (EntityMap['distributions'], EntityMap['key-releases']). Amounts are decimal strings in USDC base units; convert them with BigInt.

const usdc = (base: string) => `${BigInt(base) / 1_000_000n}.${(BigInt(base) % 1_000_000n).toString().padStart(6, '0')}`
const lane = await api.detail('lanes', laneId)
if (lane.ok) console.log(`${usdc(lane.data.vaultBalance)} USDC waiting`)

Preview, sign and send

Every action is a wallet intent. previewTransaction returns the transaction to sign, or the reasons it cannot be built.

Build an intent

Write the intent as an object, or use walletIntents, which fills in action and validates every field (it throws a Zod error on invalid input):

import { walletIntents } from '@sealroom/contract'

const intent = walletIntents.openLane({ authority: owner, modelId, nodeId })
// { action: 'open_lane', authority, modelId, nodeId }

Helpers exist for every intent: createModel, openLane, proposeSplit, applySplit, cancelSplit, distribute, prepareClaim, claim, depositBond, requestUnbond, cancelUnbond, withdrawBond, registerPlatform, registerNode, openDispute, claimDispute and claimForfeitedDeposit.

Preview

const preview = await api.previewTransaction(intent)
if (!preview.ok) throw new Error(preview.error.message)

const p = preview.data
if (p.blockedBy.length || !p.submittable || !p.serializedTransaction) {
  throw new Error(`${p.message} (${p.blockedBy.join(', ')})`)
}
console.log(`Fee: ${p.estimatedFeeLamports} lamports, signers: ${p.requiredSigners.join(', ')}`)

Sign and send from a server or agent

Deserialize the transaction, sign it without changing the message, and send it to your Solana RPC:

import { Connection, Keypair, VersionedTransaction } from '@solana/web3.js'

async function signAndSend(serialized: string, wallet: Keypair, connection: Connection) {
  const tx = VersionedTransaction.deserialize(Buffer.from(serialized, 'base64'))
  tx.sign([wallet]) // keeps any co-signature already present
  const signature = await connection.sendRawTransaction(tx.serialize())

  for (;;) {
    const { value: [status] } = await connection.getSignatureStatuses([signature])
    if (status?.err) throw new Error(`Failed on chain: ${JSON.stringify(status.err)}`)
    if (status?.confirmationStatus === 'confirmed' || status?.confirmationStatus === 'finalized') return signature
    await new Promise((r) => setTimeout(r, 700))
  }
}

const signature = await signAndSend(p.serializedTransaction, wallet, new Connection(rpcUrl, 'confirmed'))

The registry reflects the change a few seconds later, once the indexer has read it.

Sign in the browser

With a Wallet Standard wallet, ask for a signature only, then send the signed bytes yourself:

import { SolanaSignTransaction, type SolanaSignTransactionFeature } from '@solana/wallet-standard-features'

const feature = wallet.features[SolanaSignTransaction] as SolanaSignTransactionFeature[typeof SolanaSignTransaction]
const [out] = await feature.signTransaction({
  account,
  transaction: Uint8Array.from(atob(p.serializedTransaction), (c) => c.charCodeAt(0)),
  chain: 'solana:mainnet',
})
const signature = await connection.sendRawTransaction(out.signedTransaction)

Example: list a model and open a lane

import { walletIntents } from '@sealroom/contract'
import { getAssociatedTokenAddressSync } from '@sealroom/contract/solana'
import { PublicKey } from '@solana/web3.js'

const params = await api.parameters()
if (!params.ok || params.data.usdcMint.status !== 'set') throw new Error('Parameters unavailable')
const mint = new PublicKey(params.data.usdcMint.value)
const usdcAccount = (owner: string) => getAssociatedTokenAddressSync(mint, new PublicKey(owner), true).toBase58()

const runtimes = await api.list('runtimes', { status: 'allowed' })
const operators = await api.list('operators', { limit: 1 })
if (!runtimes.ok || !operators.ok) throw new Error('Registry unavailable')
const operator = operators.data.items[0] // the operator entry names an existing payout account

const created = await api.previewTransaction(walletIntents.createModel({
  authority: owner,
  manifestHash,
  keyBroker: { publicKey: brokerKey, url: 'https://broker.example.com' },
  policy: { allowedRuntimes: [runtimes.data.items[0].measurement], minTier: 1, minBond: '0', operatorAllowlist: [] },
  price: { scheme: 'exact', perCall: '25000' },
  derivativeRoyaltyBps: 0,
  baseModelId: null,
  split: [
    { recipientAta: usdcAccount(owner), recipientOwner: owner, bps: 7500, locked: false, role: 'owner' },
    { recipientAta: operator.payoutAta, recipientOwner: operator.authority, bps: 2500, locked: false, role: 'operator' },
  ],
}))
if (!created.ok || !created.data.serializedTransaction) throw new Error('Cannot list')
await signAndSend(created.data.serializedTransaction, wallet, connection)
const modelNumber = created.data.intent.action === 'create_model' ? created.data.intent.modelId : undefined

Once the model is indexed, open a lane on each node in its eligibleNodeIds:

const models = await api.list('models', { owner })
const model = models.ok ? models.data.items.find((m) => m.modelId === modelNumber) : undefined
for (const nodeId of model?.eligibleNodeIds ?? []) {
  const lane = await api.previewTransaction(walletIntents.openLane({ authority: owner, modelId: model!.id, nodeId }))
  if (lane.ok && lane.data.serializedTransaction) await signAndSend(lane.data.serializedTransaction, wallet, connection)
}

Example: distribute a lane, preparing claims when needed

import idl from '@sealroom/contract/idl' with { type: 'json' }
import { getLaneDistributionPreparation } from '@sealroom/contract/solana'

let preview = await api.previewTransaction({ action: 'distribute', authority, laneId })
if (preview.ok && preview.data.blockedBy.includes('CLAIM_PREPARATION_REQUIRED')) {
  const prep = await getLaneDistributionPreparation({ authority, laneId }, { connection, programId, idl })
  for (const c of prep.missingClaims) {
    const claim = await api.previewTransaction({
      action: 'prepare_claim', authority, laneId,
      recipientAta: c.recipientAta.toBase58(), recipientOwner: c.beneficiary.toBase58(),
    })
    if (claim.ok && claim.data.serializedTransaction) await signAndSend(claim.data.serializedTransaction, wallet, connection)
  }
  preview = await api.previewTransaction({ action: 'distribute', authority, laneId })
}
if (preview.ok && preview.data.serializedTransaction) await signAndSend(preview.data.serializedTransaction, wallet, connection)

programId is params.data.programId.value from api.parameters(). getLaneDistributionPreparation also returns available, the vault balance the distribution will split.

x402 payment helpers

@sealroom/contract/solana builds the payment for a paid call from the node's 402 challenge. Call a model walks through a full call.

FunctionDoes
buildX402PaymentTransaction(requirements, caller)Builds the unsigned payment transaction: the facilitator as fee payer, your USDC transfer to the lane vault (or the escrow of your ceiling for upto) and the invoice memo
buildX402PaymentPayload({ requirements, resource, signedTransaction })Wraps the signed transaction (a VersionedTransaction, bytes or base64) in the x402 v2 payload
encodeX402PaymentSignature(payload)Encodes the payload for the PAYMENT-SIGNATURE header
buildX402PaymentSignature({ requirements, resource, wallet })All three in one, for a wallet with publicKey and signTransaction

None of them broadcasts: the facilitator submits the payment after the node accepts it.

import type { PublicKey, VersionedTransaction } from '@solana/web3.js'
import { x402RequiredSchema, x402SettlementSchema } from '@sealroom/contract'
import { buildX402PaymentSignature } from '@sealroom/contract/solana'

async function callModel(endpoint: string, body: string, wallet: { publicKey: PublicKey; signTransaction(tx: VersionedTransaction): Promise<VersionedTransaction> }) {
  const quote = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json' }, body })
  if (quote.status !== 402) throw new Error(`Expected 402, got ${quote.status}`)
  const challenge = x402RequiredSchema.parse(await quote.json())

  const header = await buildX402PaymentSignature({ requirements: challenge.accepts[0], resource: challenge.resource.url, wallet })
  const paid = await fetch(endpoint, { method: 'POST', headers: { 'content-type': 'application/json', 'PAYMENT-SIGNATURE': header }, body })
  if (!paid.ok) throw new Error(JSON.stringify(await paid.json()))

  const settlement = x402SettlementSchema.parse(JSON.parse(atob(paid.headers.get('PAYMENT-RESPONSE')!)))
  return { completion: await paid.json(), settlement }
}

// A Keypair as the wallet:
const wallet = { publicKey: keypair.publicKey, signTransaction: async (tx: VersionedTransaction) => (tx.sign([keypair]), tx) }

The schemas x402RequiredSchema, x402RequirementsSchema, x402PayloadSchema, x402SettlementSchema and usageVoucherSchema validate each message of the exchange.

Account addresses

pda derives every program account address. Pass the deployment's programId as the last argument:

import { pda } from '@sealroom/contract/solana'
import { PublicKey } from '@solana/web3.js'

const programId = new PublicKey(params.data.programId.value)
const model = pda.model(owner, '1', programId)        // ["model", owner, id]
const lane = pda.lane(model, nodeId, programId)       // ["lane", model, node]
const operator = pda.operator(authority, programId)   // ["op", authority]
const dispute = pda.dispute(operator, '0', programId) // ["dispute", operator, sequence]
const claim = pda.claim(lane, recipientAta, programId)

Also available: pda.config, pda.verifiers, pda.platform, pda.node, pda.attestation, pda.runtime, pda.platformPin, pda.gpuPin and pda.claimantDisputes.

Schemas

Every record and message has a Zod schema you can use to validate data from any source: modelSchema, laneSchema, nodeSchema, attestationSchema, operatorSchema, disputeSchema, distributionSchema, keyReleaseSchema, walletIntentSchema, transactionPreviewSchema, protocolParametersSchema, eligibilitySchema, splitSchema, policySchema, priceSchema, and the key broker's brokerKeyRequestSchema and brokerKeyResponseSchema.

import { splitSchema } from '@sealroom/contract'

const check = splitSchema.safeParse(entries) // 1 to 8 entries, distinct recipients, summing to 10,000 bps
if (!check.success) console.error(check.error.issues)