Mini-apps (spec 073)¶
The Apps section serves third-party packages: self-contained React bundles published to IPFS,
curated on-chain by the MiniAppRegistry, fetched and hash-verified by the host, and executed from
a Blob URL inside a restricted context object.
This guide serves two readers. Sections marked [host] are for a FairWins engineer maintaining the platform side; sections marked [vendor] are for anyone building a package. Everything under "The runtime contract" is read by both — it is the interface between them, and neither side may change it alone.
- Contract:
contracts/apps/MiniAppRegistry.sol(interfacecontracts/interfaces/IMiniAppRegistry.sol) - Spec:
specs/073-miniapp-platform/; normative runtime contract incontracts/host-context.md - Starter repo: https://github.com/chippr-robotics/chippr-miniapp-template
- Operator runbook:
docs/runbooks/miniapp-registry-operations.md— the other half of spec 073 T045. Curator procedures (review, suspension, deprecation, the role handoff, gateway config, diagnostics) live there; this guide describes them in outline only.
Where this guide and contracts/host-context.md disagree on a detail, this guide matches the code:
the spec's interface block predates host.networks(), types store.set as void, and omits
store.get's fallback argument.
The trust model¶
A mini-app is untrusted third-party code running in the member's page, in the member's origin. There is no iframe and no worker — the package is imported into the host realm, so it shares the DOM, the network stack and the origin's storage with the host. That is a deliberate v1 choice (spec §Assumptions: "curation is the trust boundary"), and it fixes what everything else has to do:
- Curation is enforced on-chain. Only a package a curator approved, at a content hash the curator committed to, is ever fetched.
- Integrity is verified before execution. keccak256 of the manifest bytes against the registry, then SHA-256 of every file against the manifest — then, and only then, a Blob URL.
- The
hostobject is the entire privileged surface. Everything a package can do that a plain script could not, it does throughhost. There is no signer, no wagmi context, no storage handle, no router — only wrappers. Adding a key tohostgrants it permanently to every third-party package that will ever be approved, so additions are a specification decision, not a convenience.
What this model does not claim: a package that gets approved can still read the DOM, hit the network, and burn CPU. The registry is the defence, and curation review is a real review.
Repository map¶
| Concern | Path |
|---|---|
| Registry contract | contracts/apps/MiniAppRegistry.sol, contracts/interfaces/IMiniAppRegistry.sol |
| Registry reads (catalog + launch) | frontend/src/lib/miniapps/registryClient.js |
| Curator authority read | frontend/src/lib/miniapps/registryAuthority.js |
| Manifest schema + validation | frontend/src/lib/miniapps/manifest.js |
| Hash verification | frontend/src/lib/miniapps/integrity.js |
| Fetch → verify → import | frontend/src/lib/miniapps/loader.js |
| Shared-module scope | frontend/src/lib/miniapps/hostScope.js (installed in frontend/src/main.jsx) |
The host object |
frontend/src/lib/miniapps/hostContext.jsx |
| Namespaced store | frontend/src/lib/miniapps/store.js |
| Catalog / workspace / submit surfaces | frontend/src/components/miniapps/ |
| Curator review tab | frontend/src/components/admin/MiniAppReviewTab.jsx |
| Audit entries | frontend/src/data/ledger/sources/miniAppSource.js |
| Build preset | tools/miniapp-build/ |
| Publish script | scripts/miniapps/publish.js |
| First-party packages | frontend/miniapps/token-mint/, frontend/miniapps/clearpath/ |
| Package cache | frontend/public/sw.js (fairwins-miniapp-packages-v1) |
| Store artwork + store bar (spec 077) | frontend/src/components/miniapps/appArt.jsx + appArtwork.js, StoreBar.jsx, storeViews.js |
The store surface [host] (spec 077)¶
The catalog's visual layer. Three rules keep it from becoming a trust surface:
- Artwork is host-curated, full stop. Card illustrations live in
appArt.jsx(the SVG components) behindappArtwork.js(the slug map), keyed by the same slug the launch route uses, with a deliberate generic fallback —artworkFor()is total over any input. Nothing on-chain, in a manifest, or in a package may supply catalog imagery: an on-chain icon field would widen the registry's trust surface, package art would move keccak-committed bytes, and either would hand vendors a self-served image channel into the store. Adding an app's art is an ordinary reviewed host change. All of it is decorative (aria-hidden); the card's text is the identity. - The "On-chain verified market" badge is a factual claim, so it renders only over a
verified listing — never the stale snapshot, the outage, or the registry gap. If you touch the
header, keep that gate;
storeRedesign.test.jsxpins it across every state. The badge is the ONLY trust copy on the surface (iteration 2): the explanation prose was removed deliberately — do not reintroduce it; this guide and the runbooks are where the depth lives. - Rows promise nothing the sheet walks back. Catalog entries are tappable store rows; the
launch affordance, the My Apps toggle, and both launch-refusal explanations live in the
app-details sheet (
AppSheet.jsx), gated on the SAMEverified/slug pair the cards used.appSheet.test.jsxpins the dialog mechanics (labelling, Escape/backdrop/close, focus restoration to the invoking row). - Store navigation is presentation state, not navigation. Market / My Apps / Search ride the
Apps tab's existing
?view=seam (storeViews.jsresolves it, totally — unknown values are the market;view=submitkeeps its exclusive surface). Every sub-view is a lens over the ONE fetched listing: My Apps is favorites ∩ the launchable-filtered list and inherits the market's launch rules; nothing refetches on a view switch. The host's global navigation (spec 069) is untouched. - Sections are BOUNDED rails (spec 093 follow-up). Each category (and the Operator tools
section) renders through
CatalogSection.jsx: one horizontal rail of fixed-width cards capped atSECTION_VISIBLE_CAP(10), so a long category can never push the sections below it off the screen — the PinnedAppsStrip rule at store scale. Past the cap, a "Moreapps (+N)" control BELOW the rail (never a trailing card inside the scroller — it would be the first thing the scroll hides) opens a full-list modal with AppSheet's dialog mechanics. A details sheet opened from inside that modal owns the next Escape ( dismissLocked), so one press never closes both layers.catalogSection.test.jsxpins the cap, the disclosure, and the layering.
The registry [host]¶
MiniAppRegistry is a fund-free UUPS proxy (UUPSManaged), deployment keys miniAppRegistry /
miniAppRegistryImpl. It holds no value, has no oracle path, and has no …WithSig twins — vendor
and curator actions are infrequent and are paid by their own actors.
One home per cohort¶
// frontend/src/config/networks.js
const MINIAPP_TESTNET_CHAIN_ID = 63 // Mordor — NOT TESTNET_CHAIN_ID (Amoy)
export function miniAppChainId() {
return buildIsTestnet() ? MINIAPP_TESTNET_CHAIN_ID : MAINNET_CHAIN_ID
}
Deployment targets are Polygon 137 and Mordor 63 only. Amoy is deliberately not one, which is
why this is the single reference chain in the estate that does not derive from
TESTNET_CHAIN_ID — deriving it would resolve every testnet build to a chain with no registry.
Never hardcode 137: the catalog decides which packages the host executes, so crossing the cohort
boundary would run mainnet-curated code against testnet wallets. miniAppChainId() is not
runtime-configurable, on purpose.
Live addresses:
| Chain | miniAppRegistry |
Implementation | Deploy block |
|---|---|---|---|
| Polygon 137 | 0x5a168Cc9FeFaf40e7BC536C8C61669e6d547A0A2 |
0x41858006aD6dd0788b84F9fb17A28d8167C7b331 |
91265680 |
| Mordor 63 | 0xFEd626025225A3B1aB3BA72D429B8c9C74cb5058 |
0xc8Dd8601b35aDa3AF367C9E41f24Fd0503Ced674 |
16685064 |
launchable IS the serving decision — never status¶
function isLaunchable(uint256 id) public view returns (bool) {
AppRecord storage rec = _apps[id];
if (rec.vendor == address(0)) return false;
if (rec.status == Status.Suspended || rec.status == Status.Deprecated) return false;
return bytes(rec.approved.cid).length != 0;
}
A Pending record with a prior approval is a LIVE app whose update is in review (FR-003).
status is the review state; launchable is the serving state, and every AppView carries it.
Gating on status === Approved would let any vendor take their own live app offline by submitting
anything at all. registryClient.normalizeApp reads the chain's launchable; never re-derive it
client-side.
Approval is content-committed¶
function approveApp(uint256 id, bytes32 expectedManifestHash) external onlyRole(APP_CURATOR_ROLE);
function rejectProposal(uint256 id, bytes32 expectedManifestHash) external onlyRole(APP_CURATOR_ROLE);
The hash is compared before any state change and reverts StaleProposal(expected, actual).
The original approveApp(id) read the proposed tuple at execution time, which let a vendor swap the
package after review — by front-running the transaction, or during a multisig signing window — and
get unreviewed code marked Approved. The integrity chain does not help: the substituted manifest
hashes correctly against itself. There is deliberately no id-only overload. Never add one.
Lifecycle¶
| Action | Caller | Effect |
|---|---|---|
submitApp(name, description, category, cid, manifestHash) |
any eligible vendor | new record, proposed set, status = Pending, version 1 |
submitUpdate(id, cid, manifestHash) |
vendor | writes proposed only, status = Pending. Never touches approved — the live package keeps serving |
updateMetadata(id, name, description, category) |
vendor | re-keys the name, forces status = Pending (metadata is reviewed too) |
approveApp(id, expectedManifestHash) |
APP_CURATOR_ROLE |
promotes proposed → approved (or reinstates a retained approved after suspension) |
rejectProposal(id, expectedManifestHash) |
APP_CURATOR_ROLE |
clears proposed; returns to Approved only if not Suspended and an approved tuple exists |
suspendApp(id) |
APP_CURATOR_ROLE |
reversible; reachable from Pending as well as Approved |
deprecateApp(id) |
APP_CURATOR_ROLE |
terminal; keeps approved and the name reservation |
setMembershipGate(manager, role, minTier) / setSanctionsGuard(guard) |
DEFAULT_ADMIN_ROLE |
both gates optional; a non-contract address is refused (InvalidGateConfig) |
APP_CURATOR_ROLE administers itself (_setRoleAdmin(APP_CURATOR_ROLE, APP_CURATOR_ROLE)), so
DEFAULT_ADMIN_ROLE cannot self-grant curation. The consequence is real: losing every curator key
is recoverable only through a UUPS upgrade. The role is seeded in initialize, where curator is a
required, non-zero parameter.
Vendor eligibility is the membership gate (getActiveTier(vendor, membershipRole) >= minTier,
default Silver) plus the sanctions guard, and both are skipped when unconfigured, never failed.
The tier is read only — submitting never consumes a creation quota.
Bounds: name ≤ 64 bytes, description ≤ 512 bytes, CID ≤ 256 bytes, getAppsPaged clamps limit to
MAX_PAGE_LIMIT = 25 rather than reverting. Names are folded (ASCII lower-case, runs of spaces
collapsed, ends trimmed) before keying, and idByName applies the same folding so a client can
pre-check a collision. Versions come from a persisted lastVersion high-water mark and are never
reused, including across rejections.
Authority state, stated plainly¶
All three roles (APP_CURATOR_ROLE, DEFAULT_ADMIN_ROLE, UPGRADER_ROLE) are still held by the
deployer EOA 0x52502d…F6e1 on both networks. Handover to the compliance multisig is pending,
so the curation trust boundary is not yet real — UPGRADER_ROLE is a strict superset of curation.
scripts/deploy/deploy-miniapp-registry.js prints the handover sequence: grant to the multisig
first, verify the curator grant landed, then renounce curator, upgrader, and DEFAULT_ADMIN_ROLE
last.
The runtime contract (hostApi: 2)¶
A package's entry module default-exports one React component. The host mounts it inside
MiniAppHostProvider and passes a frozen host object, also reachable via useMiniAppHost() from
the shared SDK. Ten keys, and that is the whole grant:
Object.freeze({
appId, // 'app-<chainId>-<registryId>' — the store namespace + audit attribution
wallet: Object.freeze({
address, connectedAddress, chainId, isConnected,
submit, requestConnect, switchChain,
}),
readProvider, // (chainId?) => guarded ethers Provider — THROWS
contracts, // (name, chainId?) => string | null — THROWS for undeclared names
network, // (chainId?) => descriptor | null
networks, // () => frozen number[] (this build's cohort)
store, // { get, set, subscribe } — namespaced to appId
audit, // { log(kind, refs) }
toast, // { show(message, type) }
navigate, // (to) => void
})
appId is not the URL slug. Names are editable and re-registrable; registry ids are neither, so
the namespace is keyed on app-${chainId}-${registryId} (appNamespaceKey). Renaming an app never
loses its member's data, and re-registering a released name never inherits it.
Refusal codes¶
Every refusal is a MiniAppHostError carrying .reason (one of HOST_REFUSAL) and .userMessage
(a member-facing sentence — prefer it to .message when you surface an error).
| Code | Raised by | Meaning |
|---|---|---|
wallet_absent |
submit |
no wallet, or the acting identity has no address here |
wrong_chain |
submit |
the wallet, or the acting identity, is not on the chain the payload names |
identity_locked |
submit |
a recovered legacy account exists but cannot sign right now |
bad_payload |
submit, switchChain, wait() |
the call does not match the contract (missing/invalid chainId, bad to/data/value, or wait() on a proposal / a result with no hash) |
no_write_rail |
submit |
the session has no signer and no sendCalls — distinct from "no wallet" and from "locked" |
sanctioned_account |
submit |
the acting account screened positively restricted; nothing was sent |
undeclared_contract |
contracts |
the name is not in the package manifest's allowlist |
no_read_provider |
readProvider, wait() |
no RPC endpoint is configured for that chain (spec 069 resolution) |
provider_member_blocked |
guarded provider | destroy / removeAllListeners, or any attempt to mutate the shared provider |
switch_refused |
switchChain |
the wallet declined, or cannot reach that chain |
external_target |
navigate |
the target leaves the host |
Throw vs. warn is deliberate. submit, switchChain, navigate, contracts and
readProvider throw. audit.log and toast.show warn to the console and drop; store.set
returns false. Bookkeeping and chrome must never abort the flow they are describing.
wallet.submit — and why it is not confirmation¶
The sequence, in order, because the order is the security property:
- Payload must be an object;
chainIdmust be a positive-integer EVM id — never guessed (Bitcoin's string network ids are rejected here). - Wallet present, wallet chain equals the requested chain, acting identity permitted.
- The payload is rebuilt, not forwarded. Only
to(a plain 40-hex address),data(whole 0x-bytes, default'0x') andvalue(bigint, safe integer, or an exact decimal/hex string) survive.batchandoperation(SafeDELEGATECALL) stop here. - The host screens the acting account for sanctions, live, at submit time. Only a positive
restrictedrefuses; an unreachable screening endpoint yieldsuncertainand the transaction proceeds. Your app does nothing and cannot opt out — screening in the app layer would be optional in practice, and the packages most worth screening are the least likely to cooperate. - The host picks the rail: identity first, rail second. A vault or legacy identity always goes
through the active-account path, so a passkey member acting as a Safe vault gets a proposal, not
a UserOp from their own account. A passkey personal session writes through
sendCalls; a classic personal session through its signer.sendCallsandloginMethodare deliberately absent fromhost, so a package has nothing to branch on and needs nothing.
SubmitResult is { kind: 'sent' | 'proposed', txHash, safeTxHash } plus a non-enumerable
wait(confirmations = 1). Non-enumerable so the result stays plain and serialisable.
submit resolves at BROADCAST. kind: 'sent' means the network accepted the transaction — not
that it mined, and not that it succeeded. kind: 'proposed' means nothing moved at all. An app that
awaits submit and tells the member the action is done is lying. This is Token Mint's create path,
frontend/miniapps/token-mint/src/useTokenFactory.js:
// Encoding only — no signer is involved, and none is reachable from a
// package. The host turns this call into whichever rail the member's
// session actually has.
const iface = new ethers.Interface(TOKEN_FACTORY_ABI)
const data = iface.encodeFunctionData(method, args)
const result = await host.wallet.submit({ to: factoryAddress, data, value: 0n, chainId })
// A vault action is a PROPOSAL: nothing has moved, no token exists yet,
// and there is no hash to wait on.
if (result.kind === 'proposed') {
setStatus('success')
return { proposed: true, safeTxHash: result.safeTxHash }
}
setLastTxHash(result.txHash)
// `submit` resolves at BROADCAST. Confirmation is this app's job.
let receipt = null
try {
receipt = await result.wait()
} catch {
// Either the rail reported no hash, or the read endpoint could not
// see the transaction. It was still SENT — report that honestly.
setStatus('success')
return { txHash: result.txHash }
}
if (receipt && receipt.status === 0) {
throw new Error('The token creation transaction reverted on-chain.')
}
wait() takes a confirmation count and no timeout, so the caller imposes one. The host waits
through the member's read endpoint, which is a different endpoint from the one the wallet
broadcast through, so "not mined yet" and "not visible here yet" are indistinguishable and waiting
forever is a real outcome. ClearPath's helper (frontend/miniapps/clearpath/src/useClearPath.js) is
the shape to copy:
const CONFIRM_TIMEOUT_MS = 120000
async function waitWithTimeout(result) {
let timer
try {
return await Promise.race([
result.wait(),
new Promise((_, reject) => {
timer = setTimeout(() => {
const e = new Error('confirmation timed out')
e.code = 'TIMEOUT'
reject(e)
}, CONFIRM_TIMEOUT_MS)
}),
])
} finally {
clearTimeout(timer)
}
}
A timeout is not a failure — report "may still confirm", never "failed". wait() also does not
follow a wallet-side speed-up or cancel the way TransactionResponse.wait() would, and on the
passkey rail txHash may be a UserOp hash or an intent id (taken in decreasing order of finality,
never fabricated — null if the rail returned nothing).
wallet.switchChain¶
The companion to submit's wrong_chain refusal: without it an app can name the problem and never
offer the fix, which matters for anything that browses across chains. It grants no authority — the
member still approves in their own wallet, and a decline is a typed switch_refused so the button
can return to the right state. From frontend/miniapps/clearpath/src/ExternalDaoView.jsx:
const handleSwitch = useCallback(async () => {
setSwitching(true)
try {
await host.wallet.switchChain(chainId)
} catch (e) {
// The host's refusal already carries a member-facing sentence; prefer it to the raw message.
showNotification(e?.userMessage || e?.message || 'Could not switch network.', 'error')
} finally {
setSwitching(false)
}
}, [host, chainId, showNotification])
contracts(name, chainId?)¶
A package cannot carry the address book (see "Why host config cannot be bundled"), so the host answers instead. The manifest declares both the capability and the specific names:
The two negative answers are different and must stay different:
| Situation | Result |
|---|---|
| Name not in the manifest allowlist | throws undeclared_contract |
| Name declared, no deployment on that chain | returns null (never '') |
Answering null for an undeclared name would let "you are not approved for this" pass as "it is not
deployed here". A workspace that omits the allowlist gets EMPTY_CONTRACTS — the gate fails closed.
// frontend/miniapps/token-mint/src/useTokenFactory.js
const factoryAddress = useMemo(() => {
// Declared in the manifest's `contracts` allowlist; the host throws for a
// name this package never declared, which would be a packaging bug.
try {
return host.contracts('tokenFactory', chainId)
} catch {
return null
}
}, [host, chainId])
network(chainId?) and networks()¶
network() is a flat value projection —
{ chainId, name, isTestnet, nativeCurrency: { symbol, decimals }, explorer: { name, baseUrl } | null, subgraphUrl }
— never the NETWORKS entry, which also carries rpcUrl, dex, Polymarket and passkey config.
Wrappers, never handles. An unknown chain is null, not getNetwork()'s default-network
fallback: an app must be able to say "unknown network", and must never render one chain's explorer
link against another chain's data.
networks() returns cohortChainIds() frozen — this build's cohort, never
listSupportedChainIds(), because constitution III forbids a read crossing the testnet/mainnet
boundary and that boundary is the host's to enforce. It was declined once (for one informational
card) and added for ClearPath, which is network-agnostic by design: freezing a chain roster into an
immutable package would mean a new network cannot appear without a re-publish, re-review and
re-approve.
readProvider(chainId?)¶
The spec-069-resolved read provider — the member's override first, the build default behind it —
resolved on every call, so a repointed endpoint takes effect on the next read. It throws
no_read_provider for a chain with no endpoint, which is a real condition since members own their
endpoints.
The returned Proxy has a stable identity per underlying provider (a WeakMap), so it is safe as
a React effect dependency; an earlier version minted a fresh Proxy per call and made every app that
did so spin. destroy() and removeAllListeners() return a stub that throws when called — the host
caches one provider per endpoint and every host read shares it. Writes, redefinitions and deletions
on the proxy all throw.
Packages must never hand-build a provider. From ClearPath:
The read-route toggle is gone, and its absence is the correct outcome rather than a casualty. Host-native this offered 'public' vs 'wallet' reads and hand-built providers from
NETWORKS[chainId].rpcUrl— something the platform's own guardrails forbid. A package cannot do it at all: no wallet provider is reachable, and no network config is bundled.
store¶
get(key, fallback = null) · set(key, value) → boolean · subscribe(listener) → unsubscribe.
The namespace is structural: createAppStore(account, appId) closes over the app id, and the
returned methods take a key only, so no argument a package can construct reaches another app's
data. Backed by userStorage (fw_user_<account>_miniapp_<appId>_v1) through a session Map.
setreturns "did anything change" —falsefor a forbidden or malformed key, an unserializable value, a no-op write, or a namespace overMAX_NAMESPACE_BYTES(256 KB). Nothing it does throws.undefinedremoves a key. Values are JSON snapshots and are deep-frozen on the way out.- The store rides the spec-032 encrypted backup as the non-network-scoped
miniAppStatesynced object ("Mini-app data"), keyed by app id, up to 128 apps per bundle. - The namespace follows the acting identity, so a member operating as a vault sees the vault's
data, not their personal data. ClearPath's
trackedDaoStore.jsdocuments that as a real behaviour change from its host-native version, and it is the correct reading of "whose DAOs are these".
The host audits significant writes only: a change (not a refusal or no-op), with an address and a
chain, and at most once per key per 60 s (STATE_AUDIT_WINDOW_MS, leading edge, no timer — a
trailing debounce would lose the change made just before a tab closes). Only the key is recorded,
never the value.
audit, toast, navigate¶
audit.log(kind, refs)writes aminiapp_app_loggedledger entry.kindmust match/^[a-z][a-z0-9_.-]{0,63}$/— lowercase and colon-free, which is exactly what reserves thehost:prefix for entries the host wrote (host:tx_proposed,host:tx_failed,host:app_crashed). Refs are bounded: ≤12 keys, key pattern[A-Za-z0-9_.-]{1,32}, string values truncated at 200 chars.toast.show(message, type)— message clamped to 200 chars;typeis one ofinfo | success | error | warning, anything else falls back toinfo. The type becomes a CSS class on the host's own notification element, which is why it is clamped.navigate(to)— in-app paths only, 1–512 chars. Tabs/newlines/CRs are refused before the protocol-relative check (a URL parser strips them, so'/\n/evil.example'would arrive as'//evil.example'), as are\, anything not starting with/, and//.
Host obligations, mini-app obligations¶
[host] Verify status and integrity before import (approved tuple only); contain app failures in
an error boundary outside the provider; inject manifest.styles scoped under the workspace root;
tolerate unmount/remount (the store survives); auto-audit launch, tx submit, integrity failure and
state changes.
[vendor] Default-export a mountable React component; use only host and the shared scope for
privileged behaviour (bundled pure libraries are fine); style through the package's own stylesheet;
stay functional after remount; surface transaction errors through host.toast.
The manifest [vendor]¶
manifest.json is emitted by the build preset, never hand-written. Field order is the schema and
every list is sorted, so rebuilding unchanged sources reproduces the same bytes — and therefore the
same hash. manifest.json is never listed in its own files map.
{
"schema": "fairwins-miniapp-manifest/1",
"id": "token-mint",
"name": "Token Mint",
"version": "1.0.0",
"entry": "entry.js",
"styles": ["style.css"],
"hostApi": 2,
"sharedDeps": ["@fairwins/miniapp-sdk", "ethers", "react", "react/jsx-runtime"],
"permissions": ["contracts", "network", "toast", "wallet:submit"],
"storeKeys": [],
"contracts": ["tokenFactory"],
"files": {
"entry.js": { "sha256": "08b770f429e1f1b7938e5deda8f69ecf4a8972b21b18a356acdbeb0d2fff5ec2" },
"style.css": { "sha256": "6460e7686e9bd9047d8ce380cd2fafa0f79d95f63c41001b23ec2f7715afe94e" }
}
}
| Field | Rule |
|---|---|
schema |
exactly fairwins-miniapp-manifest/1; anything else is refused |
id |
/^[a-z][a-z0-9-]{1,30}$/, and must equal the slug the host resolved (identity_mismatch otherwise) |
version |
semver-shaped /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/; display only — the registry's own version counter is authoritative |
entry |
must appear in files |
styles |
≤8, each must appear in files |
hostApi |
integer; > SUPPORTED_HOST_API raises HostApiError (a distinct class — the remedy is "update the host", not "the package is broken") |
sharedDeps |
subset of react, react-dom, react/jsx-runtime, ethers, @fairwins/miniapp-sdk |
permissions |
subset of wallet:submit, store, audit, toast, navigate, contracts, network |
storeKeys |
≤32, each /^[A-Za-z0-9_.-]{1,64}$/ |
contracts |
≤16 names matching /^[a-zA-Z][a-zA-Z0-9]{1,63}$/; non-empty without the contracts permission is a refusal — a manifest that misdescribes itself is worse than one that asks for too much |
files |
≤64 entries, each a 64-hex SHA-256 (normalizeDigestHex: case-insensitive, an optional 0x tolerated, stored lower-cased); paths pass isSafePackagePath (≤8 segments, ≤200 chars, no scheme, no leading /, no \, no whitespace or control characters) |
parseManifest takes bytes, never text (a string is refused as not_bytes) and decodes with
TextDecoder('utf-8', { fatal: true }). The full refusal vocabulary is MANIFEST_REFUSAL in
frontend/src/lib/miniapps/manifest.js.
Declare permissions honestly. Token Mint declares storeKeys: [] with the comment "listing a
storeKey it never writes would over-declare to a reviewer", and ClearPath declares sanctionsGuard
under contracts for a read only, noting that the screening decision is the host's. A curator
reads one line instead of diffing a bundled table; make that line true.
The build preset [vendor]¶
tools/miniapp-build/ is the only supported way to build a package. A package's whole
vite.config.js is a call to createMiniAppConfig:
// frontend/miniapps/clearpath/vite.config.js
import react from '@vitejs/plugin-react'
import { createMiniAppConfig } from '../../../tools/miniapp-build/index.js'
export default createMiniAppConfig({
appId: 'clearpath',
name: 'ClearPath',
version: '1.0.0',
root: import.meta.dirname,
entry: 'src/entry.jsx',
permissions: [
'wallet:submit', // register, propose, vote, queue, execute
'toast',
'store', // device-tracked DAOs, on networks with no on-chain registry
'contracts', // ExternalDAORegistry / paymentToken / sanctionsGuard, per chain
'network', // the chain roster, explorer links, network names
],
storeKeys: ['tracked', 'trackedMigratedAt'],
contracts: ['externalDAORegistry', 'paymentToken', 'sanctionsGuard'],
plugins: [react()],
})
The entry is the whole package's front door and stays a one-line adapter:
// frontend/miniapps/token-mint/src/entry.jsx
import TokensPanel from './TokensPanel'
export default function TokenMintApp() {
return <TokensPanel />
}
What the preset guarantees:
- One ES module. Lib mode,
format: 'es',inlineDynamicImports, no content hashes: output is exactlyentry.js(+style.cssif any CSS exists). The manifest plugin fails the build unless there is exactly one chunk — the host imports one verified Blob URL, so there must be no second chunk it could fetch unverified. - No host-owned dependency inside it.
hostScopePluginrewritesreact,react-dom,react/jsx-runtime,ethersand@fairwins/miniapp-sdkinto shims that readglobalThis[Symbol.for('fairwins.miniapp.host')]. A subpath of a shared package (react-dom/client,react/jsx-dev-runtime) is a build error rather than a bundled second copy.generateBundlethen proves the promise: any module resolved from a shared package's install directory fails the build. A shim rather thanrollupOptions.externalbecause a Blob URL import has no import map — a leftover bare specifier could not resolve at runtime. - A manifest that describes the real bytes. Digests are taken from the files on disk in
writeBundle.
Why host config cannot be bundled¶
Two independent mechanisms, and both are load-bearing:
config/contracts.jsreachesconfig/tenant.js, which importsvirtual:tenant— a module supplied by a Vite plugin the mini-app preset does not register. Hard build failure.- The preset sets
envPrefixto'__MINIAPP_NO_INLINE_ENV__', a prefix nothing uses, so everyimport.meta.env.VITE_*read in a package compiles toundefined. A bundledNETWORKSwould therefore report every subgraph as absent — telling a Polygon member "this network has no subgraph", which is a fabricated fact, not an outage.
A hand-copied address table is worse than it looks anyway: frozen into immutable bytes, it turns a routine redeploy into a re-publish/re-review/re-approve cycle for every installed app. Packages take configuration from the host at runtime, and a redeploy is a host release.
Nothing in frontend/miniapps/ may import from frontend/src/¶
A package is built separately and frozen at an immutable CID. A bundled copy of a React context is a
different context — the provider the host mounted would never be found. Pure data and pure logic
may be copied (Token Mint carries its own tokenFactoryAbi.js and useClipboard.js, and says why
in its config header): no host state, no context identity, no singleton requirement.
Publishing, end to end¶
1. Build and pin [vendor]¶
# real pin (credentials from the environment only)
export PINATA_JWT=… # or PINATA_API_KEY + PINATA_SECRET_KEY
node scripts/miniapps/publish.js --app token-mint
# local staging — same bytes, same verification, no pin
node scripts/miniapps/publish.js --app token-mint --dev
The pipeline is build → verify digests → hash manifest → publish → re-verify → print, and each stage exists for a reason:
- The hash is
keccak256of the raw manifest bytes on disk, never of a re-serialized object — re-JSON.stringify-ing would normalize whitespace and key order and produce a hash no host could reproduce. The package would build cleanly, submit cleanly, and refuse to launch everywhere. - Nothing is printed that was not verified first, and the manifest is re-read and re-hashed from the published location afterwards. A vendor pays for an on-chain submission; finding the mismatch after is the expensive way.
- Credentials are read from the environment only, never printed, and stripped from the build
subprocess. The script does not load
.env— export the JWT yourself. - The gateway probe is honest about the two failures it can see: a 404 or timeout is
unconfirmedand non-fatal (propagation takes time); bytes that hash differently are fatal, and the script says "Do NOT submit this tuple". --devstages tofrontend/miniapps/dist/ipfs/<cid>/…— the literal URL shape the loader builds, already gitignored — with adev-prefixed alphanumeric id (the loader'sCID_PATTERNwould refuse a hyphen). Serve that directory and pointVITE_MINIAPP_GATEWAYat it.
Unrecognised flags fail rather than being ignored: silently dropping a mistyped --devv would pin a
package meant to stay local.
The script prints the exact submitApp / submitUpdate argument lists, the category ordinals, and
the sentence that matters most on an update: the listing lands Pending; the previously approved
package keeps serving until a curator approves this one.
2. Submit on-chain [vendor]¶
Through the Submit an app panel (/wallet?tab=apps&view=submit), which encodes submitApp,
submitUpdate and updateMetadata, checks the wallet is on the registry chain at submit time, and
pre-fetches your manifest from the configured gateways to compare its keccak against the hash you
typed. A proven mismatch is the only client-side finding that blocks submission, and then only
behind an explicit acknowledgement — everything else (CID shape, name/slug advisories) is advisory,
because the chain is authoritative and the panel is not.
All length checks in the panel are byte lengths, matching the contract's bytes(name).length.
There is no version field: lastVersion is private state with no getter and can exceed both live
tuples after a rejection, so the panel refuses to predict what version you will be issued.
For scripted first-party publishing there is scripts/miniapps/submit-and-approve.js
(npx hardhat run … --network mordor, driven by MINIAPP_CID, MINIAPP_MANIFEST_HASH,
MINIAPP_NAME, MINIAPP_DESCRIPTION, MINIAPP_CATEGORY, MINIAPP_APPROVE). It fetches the
manifest, hashes the raw wire bytes, aborts on a mismatch, and — when approving — re-reads
getApp(id).proposed.manifestHash immediately before the call and passes the hash it actually
saw, never the one it was told to expect.
3. Curator review [host]¶
AdminPanel → Compliance → Mini-App Review (miniapp-review), offered when
readCuratorAuthority() reports held — and to DEFAULT_ADMIN_ROLE as well, for transparency, but
read-only. Authority is asked of the registry itself (hasRole(APP_CURATOR_ROLE, account)) and never
derived from app-wide role flags — the curator role administers itself, so DEFAULT_ADMIN_ROLE
implies nothing, and every decision control is separately gated on isCurator && onRegistryChain
rather than on having reached the tab. The read has five outcomes (not-deployed, no-account,
unverified, held, not-held) and an unread registry never renders as "you are not a curator".
The tab reads the queue with fetchCatalog({ force: true }) — a memo would hide a just-filed
submission — and per record offers verification before any decision:
verifyMiniAppPackage({ cid, manifestHash }) runs the same fetch-and-verify path as a launch,
with verifyAllDeclaredFiles: true, and never imports anything — no Blob, entry bytes discarded.
It takes a tuple rather than a record, because a proposed tuple is by definition not launchable. It
returns an outcome and never throws. The gate on the approve button:
| Verification | Approve |
|---|---|
| not run | not offered |
ok |
allowed |
VERIFICATION_FAILURE.INTEGRITY |
blocked, no override — this is a proven disagreement between the bytes and the hash |
| gateway / manifest / host-api / unexpected | allowed behind an explicit acknowledgement — a gateway outage must not make approval impossible |
Verification state is keyed by manifest hash, not record id, so a replaced package silently
invalidates a stale verification. StaleProposal is a first-class outcome: decoded, shown as a
readable sentence, and it clears the verification, the acknowledgement and any confirmation before
forcing a refresh. It is decoded from whichever shape the failure arrives in
(lib/chain/revertError.js): ethers populates error.revert only where it held the ABI at the
point the call failed — a staticCall — while the write path through an injected wallet forwards
the same revert as raw selector bytes on error.data. Reading .revert alone therefore lost this
explanation on the exact path a curator uses, leaving them "execution reverted (unknown custom
error)" — a message with no reason to re-review (#1267). Every successful decision writes a ledger entry keyed on the tx hash and
attributed to app-<chainId>-<id> — the immutable id, never the display name.
4. Launchable¶
Once approveApp lands, launchable is true and the app appears in the catalog
(/wallet?tab=apps) and at /apps/<slug>. The slug is derived from the folded registry name
(appSlug), and is null — catalogued but not URL-launchable, with the surface saying so — when the
folded name is empty, already contains a hyphen (injectivity), or fails a round-trip check.
Published today¶
Both cohorts carry the same two first-party packages at v1.0.0, Approved and launchable.
Polygon 137 (appCount() == 2) — published 2026-08-02:
| App | Id | CID | Manifest hash |
|---|---|---|---|
| Token Mint | 1 | bafybeiacl6rrcqxt55gpkguekpa3uinzfbfyzigp4mrzzeek6gnlx433bq |
0x5ec326f6…8c8b19ad |
| ClearPath | 2 | bafybeiglnxswaxkdpvno6w7srohlcvc3clzqcydzu2umjomlga4i4jefia |
0x7e511007…81caefdb |
Mordor 63 (appCount() == 3):
| App | Id | Status | CID |
|---|---|---|---|
Smoke 1785602984210 |
1 | Suspended | bafybeihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku |
| Token Mint | 2 | Approved | bafybeiacl6rrcqxt55gpkguekpa3uinzfbfyzigp4mrzzeek6gnlx433bq |
| ClearPath | 3 | Approved | bafybeiglnxswaxkdpvno6w7srohlcvc3clzqcydzu2umjomlga4i4jefia |
The ids differ between cohorts because ids are per-registry and Mordor was written to first — never
address a package by id across chains; resolve by name (idByName) or slug.
Mordor id 1 is a deploy smoke test, not a product, and it is listed here rather than quietly
omitted. It was left Approved and launchable while its CID serves no manifest.json, so for a
while every testnet member saw a catalog card that could only ever fail at the gateway. It was
suspended on 2026-08-02; the record itself is permanent, because names are never released and
deprecation is terminal. That permanence is exactly why no equivalent smoke write was made on
Polygon — and why the Polygon catalog above contains only real products.
Integrity and serving¶
The launch path, in order — the order is the security property:
- Registry read, every mount, straight to the chain.
fetchAppBySlugnever consults the catalog memo (FR-010). A cached listing can never authorise an execution. launchableonly.approvedPackageRefreadsrecord.launchable, falls back tostatus === Approvedonly whenlaunchableis not a boolean, and refuses an absent decision exactly like a negative one.record.proposedis never read here — no branch can select it.statusis read afterwards only to explain a refusal (suspended/deprecated/never-approved).- Fetch the manifest from the configured gateways in order, with per-attempt timeout
(
GATEWAY_TIMEOUT_MS = 15_000),credentials: 'omit', and a size ceiling (MAX_MANIFEST_BYTES = 64 KB,MAX_FILE_BYTES = 8 MB) checked before and after the read. verifyManifestBytesbefore parsing. keccak256 of the received bytes against the registry'smanifestHash. A malformed or zero expected hash is a refusal, not a pass.- Parse and check identity —
manifest.idmust equal the slug that asked for it. - Per-file SHA-256. Entry, then styles;
manifestFileDigestgates what may be fetched at all, so an unlisted path is never requested. - Only now, a Blob.
URL.createObjectURL(new Blob([entryBytes], { type: 'text/javascript' })), dynamicimport(),revokeObjectURLin afinally. This is the one place in the codebase that mints a Blob URL for execution. A default export that is not a function is refused.
Refusal policy: unreachable ⇒ fail over; tampered ⇒ stop (a hash mismatch is never retried
against another gateway); unverifiable ⇒ never fetched; cancelled ⇒ LoadCancelledError,
never an availability error. An IntegrityError writes miniapp_integrity_failed before the
refusal renders.
Styles are wrapped in @scope (#<rootId>) { … } and a sheet whose braces do not balance is dropped
rather than injected — a dropped sheet never blocks a launch.
CSP¶
frontend/nginx.conf and frontend/nginx.conf.template carry, identically:
blob: is the whole of the spec-073 CSP change. Packages travel as bytes over connect-src
(which already carries spec 069's scheme-wide https: grant for member-supplied RPC endpoints) and
become executable only as a Blob the app itself minted after verification. https: in script-src
— any origin may serve executable code — was the rejected alternative, and remains the line.
frontend/src/test/nginxCspScriptSrc.test.js gates both files and asserts they stay in sync.
The service worker cache is not a trust boundary¶
fairwins-miniapp-packages-v1, cache-first (CIDs are immutable), bounded to 60 entries by an LRU
index stored inside the cache at an RFC 2606 .invalid host so it can never collide with a real
gateway URL. Package URLs are classified by shape (/ipfs/<cid>/<path>, ≥3 segments), not by a
gateway allowlist, because members bring their own nodes.
The cache can neither execute poisoned bytes nor resurrect a suspended app: after every retrieval, cached or not, the loader re-checks keccak(manifest bytes) against the chain and the SHA-256 of every byte it is about to execute or inject — the entry and the declared stylesheets. Every launch re-reads the registry first.
Be precise about the scope, because it is narrower than "every file in the manifest" and
deliberately so: a launch does not fetch files it will not use (verifyAllDeclaredFiles is false
in fetchVerifiedPackage), since downloading unused assets would slow every launch and could refuse
one over a file the host never runs. A curator review passes true and checks the whole package —
"do the per-file digests match?" is a question about the package, not about the subset this host
happens to execute. The invariant either way is that nothing unverified ever runs, not that
everything declared is downloaded.
hostApi versioning¶
SUPPORTED_HOST_API = 2 (frontend/src/lib/miniapps/manifest.js), re-exported as
HOST_API_VERSION from hostScope.js — derived, never restated.
export function isHostApiSupported(hostApi) {
return Number.isInteger(hostApi) && hostApi >= 1 && hostApi <= HOST_API_VERSION
}
Older contracts stay supported. A package built against hostApi: 1 keeps running on a v2 host;
only a package declaring a version newer than the host is refused, with HostApiError and a
version message, because it expects capabilities that do not exist and would fail somewhere the
member cannot diagnose.
What a bump costs, and therefore when to spend one:
- Additive changes bump the version and break nothing. hostApi 2 added
contracts,network,networks,wallet.switchChainandSubmitResult.wait(). Existing v1 packages neither know nor care. - A removal or a semantic change cannot be shipped this way at all. Every published package is
frozen at an immutable CID; there is no way to patch one. Removing a key would break approved apps
with no remedy short of every vendor re-publishing and every re-publish being re-reviewed. Treat
hostas append-only in practice. - Every new key is a permanent grant to every package that will ever be approved. That is the
bar a proposal has to clear;
networks()was declined once against it before ClearPath made the case.
Changing hostApi touches, at minimum: manifest.js (SUPPORTED_HOST_API),
tools/miniapp-build/constants.js (HOST_API_VERSION — the preset is Node tooling and duplicates
the rules by necessity; frontend/src/test/miniapps/buildPreset.test.js asserts they agree),
specs/073-miniapp-platform/contracts/host-context.md, this guide, and the template repo's vendored
copies.
Starting a package [vendor]¶
https://github.com/chippr-robotics/chippr-miniapp-template — public, Apache-2.0, a GitHub template
repository. npm install && npm test && npm run build && npm run verify works from a clean clone.
It vendors tools/miniapp-build/ as a mirrored copy, because the preset is not published to npm
and a template whose build does not run is not a starting point. tools/miniapp-build/VENDORED.md
records the upstream commit. Drift fails loudly rather than silently — the host refuses an unknown
schema, an unknown permission, or a hostApi newer than it supports — but re-copy the preset and
bump VENDORED.md whenever it changes materially.
Also worth knowing:
src/__tests__/_host.jsxis a stub host deliberately as strict as the real one: it throws for undeclared contract names, returnsnullfor unknown chains, and keepswait()non-enumerable. A permissive stub lets a developer ship code the real host refuses. Two traps came out of the ClearPath conversion, one on each side of that line: a stub that returned a fresh object per call where the real code returns from a static map turned it into a changing effect dependency and OOM'd two suites at 6 GB, presenting as a hang — and chasing that exposed a genuine host bug,readProvider()minting a fresh guard Proxy per call, fixed with theWeakMapdescribed above.src/testing/miniapp-sdk.jsis the test-time stand-in thatvitest.config.jsaliases@fairwins/miniapp-sdkto. It is never shipped; the preset externalises the real specifier. (In this repo the equivalent alias isfrontend/src/lib/miniapps/sdkTestShim.js, wired infrontend/vite.config.jsfor Vitest only, and it re-exports the realuseMiniAppHost.)scripts/verify-package.jschecks a build the way the host will and prints themanifestHash; it also catches an emitted-but-unlisted file, which a digest check alone cannot see.docs/HOST-CONTRACT.mdis a copy ofspecs/073-miniapp-platform/contracts/host-context.md— re-copy it whenever hostApi changes.
Why Wagers is not a mini-app¶
It was planned as the third conversion and was cut during implementation (the FR-030 amendment in
spec.md; tasks T030–T033).
Scoping measured 69% of the /wagers file closure — 22 of 32 files at the time — as shared with
the host-retained home and trade surfaces: the wager list (WagerTable), wagerVm,
wagerCardHelpers, and the create/accept/resolve flows, because HomeScreen, which App.jsx
renders at /, is itself a wager surface. A package may not import from frontend/src/ and is
frozen at an immutable CID, so converting would mean the host and the package each carrying their
own copy of those files: two
edits and a re-publish for every wager fix, with guaranteed drift. That is a worse outcome for
members than not converting.
Wagers moved into Finance ▸ Transfer as a third view beside Transfer and Bridge — the three ways
money leaves that section (WAGERS_VIEW / WAGERS_PATH in frontend/src/config/appNav.js, rendered
by PayTransferPanel.jsx). /wagers redirects there; WagersPage.jsx is gone. The catalog never
lists Wagers, because listing a package that does not exist would be the catalog lying about
absence.
The general lesson for future conversions: a feature is a good package candidate when its file closure is its own. Shared UI is the disqualifier, not size.
Configuration and environment¶
| Variable | Side | Purpose |
|---|---|---|
VITE_MINIAPP_GATEWAY |
frontend | comma-separated gateway bases, ordered ahead of IPFS_GATEWAY from constants/ipfs.js. Read at call time, not module load. https: only, plus http: on localhost/127.0.0.1 |
MINIAPP_MIN_TIER |
deploy | vendor membership floor, default 2 (Silver) |
PINATA_JWT, or PINATA_API_KEY + PINATA_SECRET_KEY |
publish | pinning credentials; env only, never printed, stripped from the build subprocess |
MINIAPP_CID, MINIAPP_MANIFEST_HASH, MINIAPP_NAME, MINIAPP_DESCRIPTION, MINIAPP_CATEGORY, MINIAPP_APPROVE |
script | submit-and-approve.js |
Deploy:
npx hardhat run scripts/deploy/deploy-miniapp-registry.js --network <mordor|polygon>
npm run sync:frontend-contracts
npm run check:storage-layout # the miniAppRegistry pair is registered
The deploy script appends to an existing deployment record and aborts if miniAppRegistry is
already present, directing you to an in-place upgrade instead. It wires that network's recorded
membershipManager (on WAGER_PARTICIPANT_ROLE) and sanctionsGuard, seeds the deployer as first
curator, and prints the handover sequence.
Testing¶
# contracts
npx hardhat test test/miniAppRegistry.test.js test/upgradeable/MiniAppRegistry.upgrade.test.js
npm run check:storage-layout
# frontend — scope local runs; the full suite OOMs this environment
cd frontend
npx vitest run src/test/miniapps/
npx vitest run src/test/ledger/ src/test/nginxCspScriptSrc.test.js src/test/networks.miniapps.test.js
src/test/miniapps/ covers the loader (against real built-and-hashed tamper fixtures), integrity,
manifest, store isolation, the registry client, curator authority, slug folding, the build preset's
agreement with the frontend validator, the SW cache policy as pure functions, and the four surfaces
(catalog, workspace, submit, review).
src/test/networks.miniapps.test.js pins the reference chain: whichever chain miniAppChainId()
resolves to must carry a miniAppRegistry address, for every build chain in either cohort.
End-to-end validation, including the tamper, suspension and update-honesty scenarios:
specs/073-miniapp-platform/quickstart.md.
Not done yet¶
Stated plainly rather than implied (specs/073-miniapp-platform/tasks.md §Phase 9):
- T045 — both halves are now written (this guide and
docs/runbooks/miniapp-registry-operations.md), buttasks.mdstill carries the task unchecked. - T047 — the accessibility pass over the Catalog / Workspace / Review surfaces (axe + Lighthouse, WCAG 2.1 AA).
- T048 — the contract security review over
contracts/apps/andcontracts/interfaces/IMiniAppRegistry.sol. Adversarial review has run and its findings landed (the content-committed approval among them); the formal agent review has not. - T049 — full-suite gates and an end-to-end quickstart run.
- Role handover — curator/admin/upgrader are still on one deployer EOA on both networks.
- Mordor id 1 — the deploy smoke listing is Approved,
launchableand unfetchable, so it sits in the testnet catalog as a card that always fails to launch. It needssuspendApp(1). - Known, accepted, unfixed: name reservations are permanently unreclaimable, there is no
vendor-address rotation, no global kill switch or batch suspension, and
appIdsByVendoris unbounded.
Related¶
- Specs:
specs/073-miniapp-platform/—spec.md,plan.md,research.md(decisions R1–R12),data-model.md,contracts/host-context.md,quickstart.md ../runbooks/miniapp-registry-operations.md— the operator side: curator procedures, live cohort state, gateways, diagnostics, upgradesupgradeable-contracts.md— the UUPS rules the registry followsnetwork-endpoints.md— spec 069, whathost.readProviderresolvespasskey-accounts.md— the second write railwallet.submitchooseswhite-label-tenants.md—virtual:tenant, and why the Apps section is feature-gated per tenantactivity-ledger.md— the ledger theminiappclass writes into