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.
| Import | Contents |
|---|---|
@sealroom/contract | createSealroomClient, record types and schemas, wallet intents, x402 schemas |
@sealroom/contract/solana | x402 payment builders, PDA helpers, distribution preparation |
@sealroom/contract/idl | The sealroom program's Anchor IDL as JSON |
Create a client
import { createSealroomClient } from '@sealroom/contract'
const api = createSealroomClient({ baseUrl: '<api-url>' })
| Option | Meaning |
|---|---|
baseUrl | The API's base URL |
timeoutMs | Per-request timeout, covering connection, headers and body. Defaults to 10,000 |
fetch | A custom fetch implementation |
Methods
| Method | Route | Returns |
|---|---|---|
health() | GET /health | Service status |
parameters() | GET /v1/parameters | Protocol parameters |
summary() | GET /v1/summary | Registry 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/eligibility | Rule-by-rule eligibility |
previewTransaction(intent) | POST /v1/transactions/preview | An unsigned transaction or its blockers |
apply(input, idempotencyKey) | POST /v1/applications | An 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.
| Function | Does |
|---|---|
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)