List a model
As a model owner you keep the weights encrypted and the key in your own hands. The registry records which machines may receive the key and who is paid for each call. Listing takes four steps: encrypt the weights, run a key broker, list the model, and open lanes on the nodes that will serve it.
Any wallet can list a model. To talk with Sealroom about onboarding and buyers first, apply as a model owner at https://sealroom.cloud/apply?kind=model_owner (choose A model owner, fill in Model domain, Target buyers, Number of models and Why the weights need protecting, then Send application).
1. Prepare the weights
- Generate a random 256-bit data key. It stays in your key broker.
- Encrypt the weights in chunks with AES-256-GCM under the data key.
- Upload the ciphertext to any storage. Public storage is fine: the ciphertext is useless without the key.
- Write a manifest for the encrypted weights, for example the location of each ciphertext chunk.
- Compute the SHA-256 of the manifest. The registry stores it as the model's
manifest_hash(64 lowercase hex characters), so a node can confirm it loads exactly the weights you listed.
Nodes serve models with vLLM inside the Sealroom runtime, so package the weights in a format vLLM loads.
2. Run your key broker
Your key broker is the service that gives the data key to nodes. It answers at an HTTPS URL of at most 128 bytes and is identified by a public key. Both go on the Model account when you list.
When a node needs your model, it sends POST /v1/key-requests to your broker with its attestation evidence:
| Field | Meaning |
|---|---|
requestId | UUID of the request |
modelId | Your Model account |
evidence.nodeId | The node asking |
evidence.recentSlot, evidence.recentSlotHash | The Solana slot the evidence is bound to |
evidence.keys.hpkePublicKey | The enclave's HPKE public key (X25519 + ML-KEM-768, 1,216 bytes, base64) |
evidence.keys.tlsPublicKey | The enclave's TLS public key (32 bytes, base64) |
evidence.keys.voucherPublicKey | The enclave's Ed25519 voucher key |
evidence.tdxQuote | The Intel TDX quote |
evidence.gpuEvidence | Evidence from each GPU (1 to 8) |
Before it releases anything, the broker reads the Attestation, Platform and Model accounts on-chain and checks:
| Check | Passes when |
|---|---|
| Attestation valid | Signed by 3 verifiers, not expired, not revoked |
| Platform registered | The Intel PPID/PCK hash is on the operator's record |
| GPUs registered | Every GPU UEID is pinned to that platform |
| Runtime allowed | The measurement is on your model's own list |
| Operator eligible | Tier, bond and allowlist meet your model's policy |
| Evidence matches | The evidence hash equals the one on-chain, and the evidence verifies again |
Then it wraps the data key to the enclave's HPKE public key (suite X-Wing-draft06-HKDF-SHA256-AES256GCM) and returns:
| Field | Meaning |
|---|---|
auditId | The id of this release in the public log |
attestationId, attestationRevision | The attestation the release relied on |
wrappedKey | version, suite, enc, ciphertext and context. The associated data binds the deployment, model, node, attestation revision, evidence hash and request |
Only that enclave can unwrap the key. The weights are decrypted in the confidential VM's memory and loaded into GPU protected memory.
Every release and every denial is recorded publicly. GET <api-url>/v1/key-releases?modelId=<model> lists them with outcome (released or denied), reasonCodes, the attestation and evidence hashes, the policy hash and the slot the broker read. You can compare your broker's log with the chain at any time.
3. List the model
In the app
Open Your models (from the top bar, or List a model on the Registry page) and connect the wallet that will own the model. Fill in the List a model form:
| Field | What to enter |
|---|---|
| Manifest (text or URL of the encrypted weights) | Your manifest, or its URL. The app stores its SHA-256 |
| Price per call, USDC | The fixed price a caller pays, up to 6 decimals, for example 0.025 |
| Your share, % | Your share of each lane's revenue after the protocol fee. The rest goes to the operator of each lane |
| Runtime nodes must run | The runtime image (name and version) whose measurement nodes must run |
| Key broker URL | Your broker's HTTPS URL |
| Key broker public key | Your broker's public key |
Select List the model and sign. Below the button, the form shows the current protocol_fee and the split_delay for later split changes.
The app lists a model with:
- a fixed price per call (
exact), - the runtime you chose as the only allowed runtime,
- minimum tier 1 and no minimum bond beyond the protocol's tier minimum,
- an empty operator allowlist, so any vetted operator's node can qualify,
- a two-entry split: your share to your canonical USDC account, and the operator share to whichever operator runs each lane.
Your model gets the next free number for your wallet and appears as "Model 1", "Model 2" and so on.
From code: the full policy
The create_model intent accepts every policy option. Send it to POST <api-url>/v1/transactions/preview and sign the transaction it returns (TypeScript client).
| Field | Rules |
|---|---|
modelId | Optional. Omit it and the API picks your next free number (1, 2, 3, …) and returns it in the preview's intent |
manifestHash | 64 lowercase hex characters |
keyBroker | { publicKey, url }, the URL HTTPS and at most 128 bytes |
policy.allowedRuntimes | 1 to 8 runtime measurements (96 hex characters each) from the allowed runtimes at GET /v1/runtimes?status=allowed |
policy.minTier | 1, 2 or 3 |
policy.minBond | Minimum operator bond in USDC base units. A node qualifies when its operator's bond meets the higher of this and the protocol's minimum for the operator's tier |
policy.operatorAllowlist | Up to 16 operator ids. Empty means any vetted operator |
price | { scheme: 'exact', perCall } or { scheme: 'upto', inputPerMillionTokens, outputPerMillionTokens, maxPerCall }, all in base units |
derivativeRoyaltyBps | The royalty derivatives of this model must pay you, in bps |
baseModelId | The Model account this model derives from, or null |
split | 1 to 8 entries summing to 10,000 bps |
const preview = await api.previewTransaction({
action: 'create_model',
authority: owner,
manifestHash,
keyBroker: { publicKey: brokerKey, url: 'https://broker.example.com' },
policy: { allowedRuntimes: [measurement], minTier: 2, minBond: '0', operatorAllowlist: [] },
price: { scheme: 'upto', inputPerMillionTokens: '3000000', outputPerMillionTokens: '10000000', maxPerCall: '200000' },
derivativeRoyaltyBps: 0,
baseModelId: null,
split: [
{ recipientAta: ownerUsdc, recipientOwner: owner, bps: 7000, locked: false, role: 'owner' },
{ recipientAta: placeholderOperatorUsdc, recipientOwner: placeholderOperator, bps: 2000, locked: false, role: 'operator' },
{ recipientAta: dataProviderUsdc, recipientOwner: dataProvider, bps: 1000, locked: true, role: 'contributor' },
],
})
Which nodes qualify
A node is eligible for your model when every rule passes:
| Rule | Reason code when it fails |
|---|---|
| The node has a current attestation | no_valid_attestation |
| The attestation has not expired | attestation_expired |
| The attestation has not been revoked | attestation_revoked |
| The runtime is allowed globally and on your list | runtime_not_allowed |
| The node's platform is registered to its operator | platform_not_registered |
| The operator's tier meets your minimum | tier_below_min |
| The operator's bond meets your and the protocol's minimum | bond_below_min |
| The operator is not frozen | operator_frozen |
| The operator is on your allowlist, if you set one | operator_not_on_allowlist |
The TCB status is UpToDate | tcb_not_up_to_date |
| The node is not disabled | node_disabled |
| The protocol is not paused | protocol_paused |
Each model's eligibleNodeIds lists the nodes that pass right now. GET <api-url>/v1/eligibility?modelId=…&nodeId=… shows every rule for one pair.
4. Open lanes on nodes
A lane pairs your model with one node and owns the USDC vault that collects that node's payments for your model. Callers can only pay through an open lane.
In Your models, the table lists each model with Price, Lanes, In vaults (USDC waiting in all its lane vaults) and Open a lane on. That last column offers the eligible nodes that do not have a lane for the model yet, with nodes marked "· serving" first. Pick one and select Open lane.
open_lane creates the Lane account and its vault, and whoever sends it pays their rent. Anyone may open a lane, so an operator can also open one for its own eligible node.
The split
The split lists who receives each lane's revenue after the protocol fee. Each entry has:
| Field | Meaning |
|---|---|
recipientAta | The recipient's canonical USDC account (the associated token account of recipientOwner and the USDC mint) |
recipientOwner | The recipient's wallet. Required when the USDC account does not exist yet; the transaction then creates it |
bps | The share in basis points. All entries sum to 10,000 |
locked | A locked entry can only be lowered or removed with that recipient's signature |
role | owner, operator, base_model or contributor |
The operator entry is special: at each distribution its share goes to the payout account of the operator whose node serves that lane, whatever account the entry names. Each lane therefore pays its own operator. The entry still names an existing USDC account when you list; the app uses a registered operator's payout account. Operator entries cannot be locked.
Base-model royalties
A model can set derivativeRoyaltyBps. Any model that names it as baseModelId must include a locked base_model entry of at least that many bps paying the base model's owner. The base owner's broker can then release the base model's key only to lanes whose split pays the royalty.
Changing the split
A split change takes effect 7 days after you propose it.
- Send
propose_splitwith the new entries (modelId,split, andconsentSignerswhen you lower a locked entry). One proposal can be pending at a time. - The model's page shows "A new split takes effect" with the date.
- After 7 days,
apply_splitmakes it the model's split. Sealroom's keeper applies matured splits automatically, and anyone may sendapply_split. - Until then,
cancel_splitwithdraws the proposal.
Each lane switches to the new split at its next distribution. Funds already in a lane vault are distributed under the split that was active when they arrived; payments after the switch use the new split. Payouts deferred to a claim stay with their original recipients.
Revenue and distribution
Your model's page (Models, then the model) shows:
- Model account:
model,owner,manifest_hash,key_broker,min_tier,min_bond,runtimesandoperator_allowlist("any vetted operator" when empty). - Split: each entry's role, whether it is locked, its percentage and recipient account.
- Lanes: for each lane, In the vault, Total in and Distributed, with Call and Distribute.
- Distributions: each distribution's time, lane, gross amount and number of recipients.
Distribute sends distribute for that lane. One transaction pays the protocol fee, the operator's share and every split recipient, all at once or not at all. Anyone can send it, and the sender pays the network fee. You can distribute whenever a vault holds funds.
The API adds totals: revenue on a model gives totalIn, totalDistributed and claimable, and each lane reports vaultBalance, totalIn, totalDistributed, lastDistributedAt and its activeSplit.
Claims
A distribution pays each recipient straight into their canonical USDC account. When a recipient's account cannot receive (it is missing, frozen, or has changed owner), that recipient's share is held in the lane as a claim instead, and everyone else is paid as usual.
- If a recipient needs a claim account that does not exist yet, the distribute preview returns
CLAIM_PREPARATION_REQUIRED. Sendprepare_claim(laneId,recipientAta,recipientOwner) for that recipient first; anyone can. Then distribute again. - The held amount appears in the lane's
claimablelist with the recipient and amount. - The recipient sends
claimwith thelaneIdandrecipientAtato receive it, into their canonical USDC account or thedestinationAtathey name, as the deployment's claim-destination policy allows.
Claims belong to their recipient and do not expire.