Different audiences
The validator’s surface is for advanced users; the host’s is for app developers who just want a backend.
You came to build an app, not to operate blockchain infrastructure. The whole point of the BaaS tier is that the hard parts — key custody, multisig signing, cross-chain signature verification, transaction submission — happen behind an API you can call with a bearer token.
The platform deliberately splits responsibilities across two tiers (see BaaS / smart-host):
| Tier | Role | Audience |
|---|---|---|
| Validator cluster | The chain runtime: holds key shares, runs DKG/TSS, signs, submits, reads chains. | Operators, custodians, sovereignty-aware customers. |
| Smart Host | The Backend-as-a-Service: auth, accounts, tokens, functions, metering. | Application developers. |
Why split them at all?
Different audiences
The validator’s surface is for advanced users; the host’s is for app developers who just want a backend.
Different security boundaries
The host can be deployed in many environments (per-customer, per-region) while the validator cluster stays small and audited.
Different scaling profile
The validator is rare and heavy; the host is fan-out and light. Decoupling lets each scale independently.
This is the single most important fact about the host:
The host holds no blockchain keys and runs no chain code. Every operation that requires a signature or a chain read is delegated to the validator over a cluster-internal API.
Even checking a wallet signature is delegated: the host does not verify it locally, it asks the validator’s stateless verifier.
sequenceDiagram
participant App as Your App (SDK)
participant Host as Smart Host (BaaS)
participant Val as Validator cluster
participant Chain as Chain (Hedera / XRPL)
App->>Host: High-level call (login, deploy, token op)
Note over Host: No keys, no chain code here
Host->>Val: Cluster-internal delegate (verify / sign / read)
Val->>Chain: Signed transaction or read
Chain-->>Val: Result
Val-->>Host: Result
Host-->>App: Bearer-token JSON response
The host’s capability set is built from six pillars:
auth/
Wallet sign-in, NFT-gated access, JWT issuance. See Web3 Authentication.
functions/
Sandboxed serverless JavaScript. Code is mandatorily signed (Ed25519) and runs in a hardened isolate under a per-function memory limit and timeout. Invocations are tier-quota’d per end-user and per agent, and are started by an HTTP call. Definitions and code persist and survive a host restart. Detailed below under Serverless functions, production-grade.
database/
A trustless CRDT op-log, sealed at rest, with verifiable Merkle inclusion proofs.
storage/
Object storage in the platform’s private object store: a
validator-only volume on the app’s home cluster, with a verified copy on a peer
validator. Objects are never published to IPFS or any public gateway.
Detailed below under The storage/ pillar.
messaging/
Pub/sub with durable channels and bounded message history, plus real-time cross-cluster delivery over SSE (see Real-time messaging over SSE).
consumption/
Per-operation usage events feeding metering. See Metering & Consumption.
On top of these, the host adds deployment (init/deploy/update/rollback of customer smart-apps) and account/token surfaces delegated to the validator.
storage/ pillarThe id an upload returns (the cid field) is an opaque id — the SHA-256 of the
sealed object — not an IPFS content identifier. Storage has a
subscription-tier-aware capacity quota, counted atomically per app so
concurrent uploads cannot jointly exceed it, and cross-cluster metadata
replication.
Every object is encrypted on the host before it leaves it (AES-256-GCM under a fresh per-object key, sealed with the cluster key; the app and the customer are bound into the cipher’s authenticated data), so the store and the peer validators that hold copies only ever hold ciphertext. Reads are owned-only and metered, including host cache hits. Customer objects require the matching verified customer; omitting that identity never grants app-wide access. Authorization and balance checks precede retrieval (what a read costs: Metering & Consumption).
An upload, a download and a delete are served by the app’s pinned ring
home: a cluster that is not the home forwards the request there before any
local lookup and relays the answer, and one that cannot reach the home answers
503 — nothing falls back to IPFS or to a local copy. An upload answers 201
only once a peer validator holds a verified copy, and 507 once the store’s
capacity for the cluster, or the app’s storage quota, is reached. A delete erases the object on the home
and on every peer that holds a copy, and drops the key from every cluster’s
index; that is a crypto-shred plus an unlink, not a physical wipe of the
volume.
Files stored before the object store existed were pinned to public IPFS, at first in the clear and later as ciphertext. They are purged, not migrated: a host deletes those index rows when it first boots on the object store, and every cluster’s validator retraction round unpins and garbage-collects their blocks and refuses to pin those CIDs again. Within one hourly retraction round, no HSuite IPFS node holds or serves them. Copies that a third-party gateway already cached, and provider records already announced to the DHT, are outside HSuite’s control and expire on their own.
A design rule that runs through the whole API: an operation every chain
shares has one generic endpoint, and the chain is a parameter in the request
body. The validator resolves the per-chain implementation from the chain
field at runtime. A chain-specific URL exists only for an operation that one
chain alone has, such as XRPL account setup.
After you create an entity or submit work, you do not poll the host or fire an HTTP trigger to make the validator do post-submit work. The cluster auto-watches on-chain state for the entities it governs and acts autonomously over its internal event bus. Your app’s job ends at the API call; the cluster’s job continues on-chain.
The four data pillars — database, storage, messaging, functions — are not a convenience cache that evaporates on the next restart. Customer BaaS data is encrypted at rest, replicated across the cluster federation, and backed by verifiable integrity proofs.
Every customer document’s data is sealed before it touches any at-rest
surface — the app’s database, the replication op-log on the wire, and the
op-log buffer. Each document is sealed with AES-256-GCM under a fresh key, and
that key is sealed under the cluster’s post-quantum ML-KEM-768 key. The
cluster key is one key per cluster, not per app. Its secret is derived from a
threshold signature of the validators and kept only in validator memory; it is
never written to disk. A raw database reader recovers nothing. In-memory state
and the responses you read back stay plaintext, so your queries keep working
unchanged.
The sealing is fail-closed: if a document cannot be sealed, the write is dropped — the platform never silently falls back to writing your data in the clear.
Each app has a single home cluster, fixed by the app’s pinned placement
ring: a hash of the appId over the ring’s clusters. Every cluster derives the
same home with no coordination, so a write on one edge and a read on another
agree. The home does not move when cluster membership changes or a heartbeat is
missed, and a host restart cannot elect a second home. While the home cannot be
reached, a write answers 503; the app is never moved to another cluster. A
host whose validator stops serving the cluster roster keeps the last one for up
to five minutes, then answers 503 too.
Writes route to the home; the home’s op-log streams to the replica clusters
over the data-plane mesh. Channels and bounded message history persist to the
database and reload at boot; function definitions and code persist and reload
too — none of it is in-memory-only. A function deploy, update or delete
answers only once its definition is saved or removed: if the function store
refuses the write, the call answers 503 and the function stays as it was.
Uploaded objects are stored in the private object store on a durable volume
that survives a restart, with a verified copy on a peer validator, and are
encrypted on the host before they leave it. They never reach IPFS: its swarm is
public, and a gateway serves any pinned CID to anyone, with no session and no
charge. What earlier releases pinned there is retracted: every hour each
cluster unpins every tenant pin its validator recorded, then garbage-collects
the blocks, so an object stored before the cutover does not stay on the public
swarm.
flowchart LR
Edge["Any edge cluster (round-robin DNS)"] -->|write| Owner["Home cluster (pinned ring, hash of appId)"]
Owner -->|sealed op-log| RepA["Replica A"]
Owner -->|sealed op-log| RepB["Replica B"]
Owner -->|persist| DB[("App database (sealed)")]
Owner -->|sealed object| Store[("Private object store (validator volume) + peer copy")]
You don’t have to trust the host’s word that your data is intact. Every write
returns a Merkle inclusion proof — {leaf, siblings, path} that verifies
against the app’s current root. Roots are sealed into epochs; each epoch
root carries a post-quantum (FIPS 204 ML-DSA-87) signature from a
validator. Anchoring each epoch root on a Hedera HCS topic is built, not yet
enabled. The receipt reports an epoch as anchored only once a
consensus-timestamped anchor is recorded, never optimistically.
The customer receipt at GET /api/v3/baas/receipts/:appId/doc/:collection/:documentId
composes the whole chain for you: the inclusion proof, the current root, the
latest signed epoch, and whether they match. The epochs themselves are at
GET /api/v3/baas/receipts/:appId/epochs/latest and
GET /api/v3/baas/receipts/:appId/epochs/:epoch.
A smart-app usually serves many end users, and it reaches the host on its own app session. Left unguarded, that turns the app into a single door through which any of its users could read or write any other user’s data — a classic IDOR. The platform closes that on the host itself, so a forked or buggy app can’t opt out by accident. An external penetration test drove this hardening across every plane; the guarantees below are enforced today:
Reads are owner/customer-scoped
A request scoped to a customer sees only what that customer owns. This holds for storage, database, messaging, functions, entities, and agents alike. The app acting as itself sees its own tenant-wide records, except on the entities and agents planes, where it sees only the objects it owns itself.
A foreign object is indistinguishable from an absent one
Asking for an
entity, function, or agent you don’t own returns the same 404 — same status,
same body — that an id which never existed returns. Possession of an id reveals
nothing, so the endpoint can’t be probed for what a cluster holds. The owner
routes — an agent’s, and an entity’s transfer, withdraw, mint, burn, compliance,
trustline, delete, restore and execute-create — answer the same 404 as the
reads. An agent’s entity also belongs to the app that created it: a session for
another app gets that 404 too, and the entity is left out of its list.
Channel names are per customer
Each customer, and the app acting as
itself, has its own messaging channel names. Creating or publishing to a name
another customer uses gives you a channel of your own, with the answer a free
name gets, so names can’t be probed either. A channel name containing a
control character is refused with the same 400 wherever a channel is looked up.
List routes can only ever narrow to you
An ?owner= query naming a foreign
wallet returns the same empty page an owner with no records would — it never
widens your view, and it doesn’t even reach the store.
Writes are attested to the acting customer
Every BaaS write records who
wrote it — the acting customer, not the app’s shared service wallet. For the
database that actor is folded inside the Merkle leaf
(provenance.createdBy / updatedBy), so the attestation is a real
cryptographic statement about the actor rather than about the application, and a
later writer is recorded without erasing the original creator.
Which customer a request is scoped to comes from the forwarded customer session; a bad or foreign session is refused, never silently downgraded to the app’s own authority. See Web3 Authentication → the customer session for the forwarding model and the exact fail-closed contract.
The default is private — an app-owned collection, channel, or file is the app’s own, and a forwarded customer never sees it. When you want every one of your customers to read a piece of app-owned data (a shared product catalog, a public announcement channel, a terms-of-service file), mark it shared. Absence of the flag is private; there is no accidental exposure.
shared: true grants READ only. A shared row lets every customer of the app
read it — database find / get / inclusion-proof, channel get / list /
history, file metadata / list / exists / download. Every write stays
owner-bound: a customer still cannot update or delete a shared document, publish
to a shared channel, or delete a shared file. A shared row is also not folded
into a customer’s own records — it never shows up as theirs in a list, and it
doesn’t count against their usage.
Only the app acting as itself can flip the flag. The three share routes are
app-scope-only; a forwarded customer identity is refused (403), and a
customer-bound object can never be marked shared (it already belongs to one
customer). The flag is per app, persisted and replicated exactly like the
ownership binding, and returned in collection-list, channel-info and
file-metadata responses so you can read back the current state.
| Plane | Set at creation | Flip later | Route |
|---|---|---|---|
| Database | createCollection(name, { shared: true }) |
setCollectionShared(name, bool) |
PATCH /api/v3/baas/db/:appId/collections/:collection/shared |
| Messaging | createChannel(name, { shared: true }) |
setChannelShared(name, bool) |
PATCH /api/v3/baas/messaging/:appId/channels/:channel/shared |
| Storage | upload(file, filename, { shared: true }) |
setShared(cid, bool) |
PATCH /api/v3/baas/storage/:appId/:cid/shared |
// Acting as the app itself (app-owner session), publish a read-only catalog.await baas.database.createCollection('catalog', { shared: true });await baas.storage.setShared(brochureCid, true);
// Any logged-in customer may now READ it on their own forwarded session…await baas.database.find('catalog', {}, { customerToken: endUserSessionToken });// …but a write on the shared collection still fails owner-bound.The functions/ pillar is a hardened serverless runtime, not a toy sandbox.
Every property below is enforced today:
Code is always signed
Deploy, update, and one-shot eval all require a
valid Ed25519 signature over the code — there is no unsigned path in any
environment. The service computes the SHA-256, checks the signed package
matches the submitted code, verifies the signature, and writes an audit entry
(deploy / update / verify / reject / eval / invoke). A mismatch
or bad signature is rejected before the code ever runs.
Durable audit trail + invocation stats
Every audit event, including each invoke, is persisted, so the trail
survives a host restart. Per-function invoke stats (last-invoked time, counts)
are persisted the same way, and a function’s createdAt timestamp is surfaced
on read. Recording never blocks or fails the call being audited.
Per-function memory limit
The isolate is created with the function’s declared memory, clamped to a safe platform range (64–512 MB, default 128). A function that declares 512 MB gets 512 MB.
Tier-aware per-principal quotas
A customer-attributed invocation is gated by a per-customer daily cap derived from the subscription tier (the tier’s monthly function-invocation allowance divided by 30, unlimited tiers exempt; untiered customers fall back to a flat default). An agent-triggered invocation is gated by a separate per-agent daily call cap and a per-agent daily USD spend cap priced against the served app’s tier — so one runaway agent can’t drain the app’s subscription float. All quota reads are fail-open: a metering blip never DoSes a working function (the isolate is the hard security boundary; quotas are abuse/cost control). Over-limit returns HTTP 429.
Ephemeral eval
POST /api/v3/baas/functions/:appId/eval runs signed code
once and discards it — same trust model and isolate as a deploy, but nothing is
persisted. Built for AI-generated / throwaway snippets. Still a metered,
quota’d invocation.
Source is retrievable
GET /api/v3/baas/functions/:appId/:functionId/code
returns a deployed function’s source alongside its codeHash (the SHA-256 the
signature was checked against), so a “view body” surface can show exactly what
runs and verify the integrity hash. Like the read of a function’s metadata, the
read is routed to the app’s home cluster, so the source is available
immediately after a deploy and identical from any cluster you hit.
HTTP invocation
A function runs when it is called:
POST /api/v3/baas/functions/:appId/:functionId/invoke. A function definition
also accepts a schedule or event trigger, but those triggers are not wired
yet and do not fire on their own.
flowchart LR Sign["Signed code package (Ed25519)"] -->|deploy or eval| Verify["Verify signature + hash"] Verify -->|quota check per principal| Quota["Tier-aware daily caps (HTTP 429 over-limit)"] Quota --> Isolate["Hardened isolate (per-function memory limit + timeout)"] Isolate --> Meter["Consumption events"]
fetch() fails safeYour function code can call fetch() to reach an outside API. Inside the
isolate you get a minimal Response back — ok, status, and await res.text() / await res.json() — enough to talk to a normal JSON endpoint.
The property worth knowing is what happens when that call fails — a
DNS/connection/TLS/timeout error, or a request the platform’s SSRF guard blocks.
A failed fetch() behaves like an ordinary JavaScript rejection you can reason
about:
try/catch works normally. The failure is thrown inside your function,
where your own catch sees the real cause. Handle it and carry on:
async function handler() { try { const res = await fetch('https://api.example.com/price'); return await res.json(); } catch (e) { // e.message carries the real cause, e.g. "fetch failed" return { ok: false, reason: e.message }; }}An uncaught fetch failure is a clean result, not a crash. If you don’t
catch it, the invocation comes back as a normal result with success: false
and error set to the real cause (e.g. fetch failed) — not a generic
"Unknown error", and never a host-wide outage.
A refused destination always reads the same. Whatever the guard objected
to — not https, not declared, a name that resolves into private space, a name
that does not resolve at all — the error is fetch blocked: destination not permitted (https to the app's own host or its declared egress, at a public address). The specific reason is logged on the host, not returned: a distinct
reason per case would tell a caller which in-cluster names exist.
Where a sandbox fetch() may reach. The guard runs untrusted, customer-authored
code, so it lets your function reach only the hosts your app declared, over
https://, and blocks everything that could be a way back into the platform:
Your app’s own host, plus the egress your app declared. A function (deployed
or eval) reaches https://<your-app>.<smart-app domain> and the hosts in your
deploy manifest’s backend.egress that the cluster operator’s destination
allow-list permits — the same declaration and the same operator list that open
egress for your app’s pod. Every other host, another app’s included, is refused
before it is resolved, and every redirect hop is checked against the same list.
A declaration is host and port: a host declared on 443 is refused on 8443,
and your app’s own host is reached on 443 only.
A function can narrow that list further. Deploy it with egress: ["discord.com"]
and it reaches only those of your app’s declared hosts; egress: [] keeps it on your
app’s own host. A host your app does not declare is refused at deploy (400). Without
egress the function keeps your app’s whole list, as before.
https:// only. A http:// (or any non-HTTPS) URL is refused before it
leaves the isolate.
No private, loopback, link-local, CGNAT, multicast or reserved addresses.
That includes the cloud metadata address 169.254.169.254 — the classic
SSRF target — for IPv4, IPv4-mapped IPv6 and NAT64 (64:ff9b::/96,
64:ff9b:1::/48).
No in-cluster Kubernetes names. A bare single-label host (my-service), a
.svc / .cluster.local name, and the <service>.<namespace> short form all
resolve only inside the cluster, so all are refused.
Resolved and pinned at connect time. The destination name is resolved
before the connection opens, and every address it resolves to must pass the
private-range check; the connection is then pinned to exactly those vetted
addresses. A public-looking hostname that resolves into the cluster or an
RFC1918 range is refused, and because the transport can’t re-resolve, DNS
rebinding has no window to swap in a private address after the check.
Redirects are followed manually and each hop is re-checked the same way, so a
validated public URL can’t 302 you into a blocked address. Your redirect
option is honoured: 'error' fails the fetch() on a redirect, 'manual'
returns the 3xx itself, and 'follow' (the default) follows up to 5 hops.
Bounded. A call gets 30 s in total, redirects included, and a response
body may be at most 5 MiB after decompression. Past either limit the fetch()
fails with a catchable error; nothing is silently truncated.
“Signed” and “authenticated” are not the same thing. By default the SDK mints a
fresh throwaway keypair on every signCode() call (including the auto-sign
inside deploy()/eval()), so the envelope proves only that the payload is
internally self-consistent — the hash matches the code, and the signature
matches the embedded public key. It says nothing about who published the
code, because that “signer” never existed before the call and is never seen
again. It’s a checksum with extra steps, not proof of provenance.
To make the signature mean something, configure a stable Ed25519 key once and reuse it on every call.
Generate the key with generateBaasFunctionSigningKey(),
then hand the operator the exact allowlist string with
baasFunctionSignerAllowlistHex():
import { BaasClient, generateBaasFunctionSigningKey, baasFunctionSignerAllowlistHex,} from '@hsuite/smart-engines-sdk';
// Once, ever — persist privateKey yourself (env var, secrets manager, …).const { privateKey, publicKey } = await generateBaasFunctionSigningKey();console.log(await baasFunctionSignerAllowlistHex(publicKey)); // give this hex to your operator
const baas = new BaasClient({ hostUrl, appId, functionSigningKey: { privateKey } });await baas.functions.deploy({ name: 'f', runtime: 'nodejs20', code, codeType: 'inline' });The operator registers that hex string against your appId in the host’s
trusted-signer allowlist.
Only once that
registration exists do you set functionSigningKey.domainBind: true to switch
to the stronger, appId-bound signature the armed allowlist checks against.
The messaging/ pillar isn’t just durable channels — clients get live
delivery over Server-Sent Events. EventSource can’t set headers, and a
session bearer in a URL ends up in every proxy, ingress log and trace it
crosses, so no route accepts a bearer from the query string. A browser first
mints a stream ticket with its bearer in headers, then opens the stream
with only that ticket in the URL:
POST /api/v3/baas/messaging/:appId/channels/:channel/subscribe-ticketAuthorization: Bearer <session>→ 201 { "ticket": "...", "expiresAt": "..." }
GET /api/v3/baas/messaging/:appId/channels/:channel/subscribe?ticket=<ticket>The stream then carries every message published to that channel in real time.
A ticket lives 30 s, opens one stream once, and is bound to the app, the
channel and the minting session (a smart-app forwarding a customer with
X-Customer-Session-Token gets a ticket bound to that customer’s own session).
It dies with the session and works on no other route.
Mint a new one for
every (re)connect — the SDK’s baas.messaging.subscribeUrl(channel) does
both steps, and baas.messaging.subscribe(channel, { onMessage }) repeats
them for every connection: it closes a failed EventSource itself (the
browser’s own reconnect would replay the spent ticket) and reconnects with
backoff. Non-browser clients may still open the stream with the bearer in
Authorization. The same global guards (JWT → rate-limit → subscription
state → app-ownership) run on both routes. A periodic ping event keeps the
stream and any proxy idle-timeout alive.
Owner-routed for cross-cluster correctness. Publishes are owner-routed —
the app’s pinned placement ring picks one home cluster whose local channel
stream the publish path feeds synchronously. The round-robin gateway can land a
subscriber on a different cluster, so every subscribe is routed to the app’s
home: if the pod you hit isn’t the home, it proxies the home cluster’s SSE
stream upstream (carrying your ?ticket= and a forwarded-marker header to
break proxy loops) and pipes it back to you. Live delivery works no matter
which cluster you connect to.
The ticket is signed with the minting host’s key and verified against the host keys every cluster already trusts, so any cluster accepts it. Single use is enforced by a store that every host replica in the cluster where the stream terminates shares (if that store is unreachable, the ticket is refused). With owner routing on (the testnet and mainnet configurations turn it on), that cluster is the home, so a replay through any cluster is refused.
Only the home serves a stream from its own channels: a cluster that cannot resolve the home (it has no pinned ring, or its validator roster, read within the last 5 minutes, does not list the home), or a non-home cluster that a stream bounced back to (its forwarded-marker header names that cluster), answers 503 before the ticket is spent, because a non-home cluster never sees the home’s channel deletes. A stream stays bound to the channel it opened on: it ends when that channel is deleted, on any cluster, and at the first message for any other record of it, such as one re-created after a delete; a reconnect is checked against the home’s channels again. Another customer’s channel of the same name is a different channel (each customer has its own names) and never reaches the stream.
With owner routing off each cluster serves locally
and spends the ticket in its own store, so a captured ticket could open one
more stream per cluster before it expires. When the home
refuses a stream (spent ticket, channel not visible), the pod you hit answers
with that status instead of an empty stream, so EventSource stops rather than
retrying a dead URL.
sequenceDiagram
participant Client as Browser (EventSource)
participant Edge as Non-home cluster
participant Owner as Home cluster (pinned ring)
participant Pub as Publisher
Client->>Edge: POST .../subscribe-ticket (bearer in headers)
Edge-->>Client: { ticket } (30 s, single use)
Client->>Edge: GET .../subscribe?ticket=...
Note over Edge: Not the home — proxy upstream
Edge->>Owner: Upstream SSE (ticket + forwarded marker)
Note over Owner: Verifies and spends the ticket
Pub->>Owner: publish message
Owner-->>Edge: SSE event
Edge-->>Client: SSE event (live)
Besides channel messages, the same ticketed stream pushes a terminal
operation event when a long-running async entity operation you own reaches
a final state — finalized or aborted. The frame is
a wake signal, not the full payload: it carries only
{ operationId, entityId, status, chain }. Fetch the complete result from the
operation’s owner-gated status endpoint when you see it:
event: operationdata: { "type": "operation", "data": { "operationId": "...", "entityId": "...", "status": "finalized", "chain": "xrpl" } }The events are app-scoped (not bound to a single channel) and owner-filtered the same way the status endpoint is — an operation you don’t own is dropped from your stream silently, never ending it. Delivery is best-effort; the status endpoint stays the authority, so a dropped event never loses you the result.
When you create a runtime topic entity, the validator creates the backing
Hedera HCS topic on chain — its keys are the entity’s own DKG slot public keys,
in a KeyList whose threshold is the entity’s own threshold, and the create
transaction is paid by the Protocol Treasury via DKG-multisig, never an
operator key. The entity’s chainAccounts.hedera is stamped with the real
topicId.
Because the topic’s admin_key and submit_key are the entity’s own DKG
KeyList, neither your app’s operator key nor anyone else can write to it
directly. The host exposes the one path that can — the entity’s threshold
signers submit on your behalf:
// `fundWith` is the wallet that pays the HCS fee and submits; the platform pays nothing.await baas.entities.submitMessage(topicEntityId, { message: 'hello, consensus', fundWith: payerAccount,});POST /api/v3/baas/entities/:entityId/messageAuthorization: Bearer <app-session>Four properties worth knowing:
The topic id is never taken from the request
The host resolves it from the
owned entity record, so a caller can’t aim an owned entity’s signature at some
other topic. DKG keys are per entity, so one entity’s signature can’t satisfy
another’s KeyList either.
Customer-funded, not platform-paid
The fundWith wallet signs as fee payer
and submits; the platform never pays for a topic message.
The topic's rule must enable it
A topic whose rule doesn’t allow
operations.submit is refused at the sign-gate with 403 before any bytes —
the host keeps it a 403.
One chunk, 1–1024 UTF-8 bytes
A larger message would be chunked, and the
DKG signs only one body, so it’s rejected. This is HCS, distinct from the
baas.messaging pub/sub channels above.
Operator-side view: Smart Host.