Ufree Mini-App Developer Guide

Full guide: publishing, container, SDK, constraints, errors

Ufree Mini-App Developer Guide

For third-party developers who want to publish a dApp as a Ufree mini-app. After reading this you should be able to:

  • Build a wallet-calling demo in under 30 lines of HTML
  • Decide whether to ship single-file + hash, or multi-file without hash
  • Know which Web APIs work inside the container, which don’t, and how to work around the gaps

中文版:MINI_APP_DEVELOPERS.md · 速查:MINI_APP_REFERENCE.md


1. Minimum viable mini-app (5 minutes)

Upload this single file to any HTTPS host (or IPFS):

<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <script src="https://YOUR-UFREE-HOST/sdk/ufree.js"></script>
  </head>
  <body>
    <button id="login">Sign in with Ufree</button>
    <pre id="out"></pre>
    <script>
      document.getElementById('login').onclick = async () => {
        const [addr] = await window.ethereum.request({
          method: 'eth_requestAccounts',
        });
        const sig = await window.ethereum.request({
          method: 'personal_sign',
          params: ['Welcome to my app', addr],
        });
        document.getElementById('out').textContent =
          'addr: ' + addr + '\nsig: ' + sig;
      };
    </script>
  </body>
</html>

Then in the Ufree app: Settings → Publish a mini-app → fill the form → Publish. Any Ufree user can now find your app on the Apps tab by searching your npub.


2. How publishing works

“Publishing” means signing a kind:30078 event (NIP-78 arbitrary app data, d tag miniapp) with your Nostr/Ufree key. The event’s pubkey is your app id; its content is your JSON manifest.

Option A — Publish from inside the Ufree app (recommended)

Ufree → Settings → Publish a mini-app. Form prefills your current live manifest; edit and click Publish. Ufree signs with your current wallet sk and pushes to your configured relays.

Option B — Publish from any Nostr client / your CI

For automated republishes from a build pipeline, sign the event yourself:

{
  "kind": 30078,
  "tags": [["d", "miniapp"]],
  "content": "{\"name\":\"My App\",\"version\":\"0.1.0\",\"bundleUrl\":\"https://my-app.com/index.html\",\"about\":\"...\"}"
}

Note: kind:30078 is a NIP-33 replaceable addressable event — relays keep only the latest event per (pubkey, kind, d tag). So republishing == updating; there is no edit-vs-create distinction.


3. Manifest schema

interface MiniAppManifest {
  name: string;          // REQUIRED display name
  version: string;       // REQUIRED semver-ish, e.g. "1.2.0"
  bundleUrl: string;     // REQUIRED entry HTML — https:// or ipfs://
  about?: string;        // short description (Apps card subtitle)
  picture?: string;      // icon URL — https / ipfs / data:image/
  bundleHash?: string;   // OPTIONAL sha256 hex of bundleUrl content
  // npub is derived from the event pubkey — do NOT include it
}

Validation:

Field Rule
bundleUrl Must start with https:// or ipfs:// (prod). Dev builds also accept http://localhost
picture Same as above plus data:image/... (handy for inline icons)
bundleHash Must be 64-char lowercase hex sha256; any malformed value is silently dropped

The whole manifest is rejected if name / version / bundleUrl is missing, empty, or the URL scheme is illegal.


4. Bundle hosting: single-file vs multi-file

Most important decision before publishing.

Mode 1 — single-file + bundleHash (strongest integrity, strictest constraints)

Bundle your entire app into one self-contained HTML file — CSS inline, JS inline, icons as data URLs. Compute sha256 and set as bundleHash.

Container behavior on load:

  • fetches bundleUrl
  • recomputes sha256, compares against manifest’s bundleHash
  • match → loads via <iframe srcdoc=...> (the exact verified bytes execute)
  • mismatch → refuses, shows “Integrity check failed”

When to pick this: pure-frontend dApp talking to chain RPC / Nostr only. No backend API, no user login session. Examples: on-chain dashboard, contract caller, airdrop claim page, NFT browser.

Constraints:

  • The iframe origin is opaque "null"any backend API CORS will reject the null origin
  • No cookies / cross-session login state (IndexedDB still works, partitioned to the container)
  • Service Workers won’t behave normally
  • Truly single-file — inline everything

Mode 2 — multi-file, no bundleHash (loosest, least work)

Skip the “Verify bundle” button in the publish form, no bundleHash in the manifest.

Container loads via <iframe src="https://your-app.com/index.html">. The iframe runs at your actual origin. Relative URLs, CSS, JS, API calls — everything works the normal way.

When to pick this: standard SPA (Vite / Next.js / anything with multiple bundled files), backend API present. Basically “ordinary web app + wallet”.

Constraints:

  • No integrity check — depends entirely on your HTTPS + host security
  • Want partial integrity back? Add integrity="sha384-..." (standard SRI) to each <script> and <link> in your entry HTML — browser-native verification of assets

Mode 3 — multi-file + bundleHash (middle ground)

If your entry HTML is fixed and only referenced assets change (e.g. CDN-hashed filenames), you can publish bundleHash to lock the entry HTML only. Container loads via srcdoc AND injects <base href="https://your-app.com/"> so relative paths resolve back to your origin.

⚠️ Caveat: same as Mode 1, srcdoc iframe origin is null — no backend API. Mode 3 only suits “entry HTML + a few static assets” pure-frontend cases.


5. SDK

<script src="https://YOUR-UFREE-HOST/sdk/ufree.js"></script> is the only integration line.

Exposes:

window.ufree.request(method, params)       // raw bridge call
window.ufree.ethereum                      // EIP-1193 provider
window.ufree.nostr                         // NIP-07-style provider

// Zero-touch aliases (if nothing else claimed these globals):
window.ethereum  =  window.ufree.ethereum
window.nostr     =  window.ufree.nostr

Those aliases are the magic — existing ethers / wagmi / nostr-tools code runs unchanged.


6. EVM integration

6.1 Vanilla window.ethereum

const [addr] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const chainHex = await window.ethereum.request({ method: 'eth_chainId' });
const sig = await window.ethereum.request({
  method: 'personal_sign',
  params: ['Hello world', addr],
});

6.2 ethers v6

import { BrowserProvider } from 'ethers';
const provider = new BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const addr = await signer.getAddress();
const sig = await signer.signMessage('Hello world');

6.3 wagmi

import { injected } from 'wagmi/connectors';
import { createConfig } from 'wagmi';
const config = createConfig({
  connectors: [injected({ target: 'ufree' })],
  // ...rest of config
});

6.4 EIP-1193 methods — supported / not supported

Method Status Notes
eth_requestAccounts Returns single-element array
eth_accounts Same
eth_chainId Returns 0x1 (placeholder until chain registry lands)
net_version Decimal string of chain id
personal_sign EIP-191 — params [message, address]
eth_sign Legacy order [address, message], equivalent inside
eth_sendTransaction Returns 4900 not yet wired — enabled when viem/ethers RPC integration lands
wallet_switchEthereumChain Same — needs chain registry
wallet_addEthereumChain Not supported
eth_call / eth_blockNumber / other reads Not supported — connect to RPC yourself

Unsupported methods reject with {code:4200, message:"Method X not supported by Ufree"}.


7. Nostr integration (NIP-07)

const pubkey = await window.nostr.getPublicKey();      // hex pubkey
const signed = await window.nostr.signEvent({
  kind: 1,
  content: 'hi from miniapp',
  created_at: Math.floor(Date.now() / 1000),
  tags: [],
});
// signed has id / sig / pubkey filled in

NIP-07 is the Nostr de-facto standard — nostr-tools and friends pick up window.nostr automatically.


8. Antelope integration

Antelope has no EIP-1193-equivalent universal standard, so for now use the raw bridge:

const accountName = await window.ufree.request('wallet.antelope.getAccount');
const chain = await window.ufree.request('wallet.antelope.getChain');

// Sign + broadcast (currently 4900 — enabled when Wharfkit Session is wired in PR-N)
const result = await window.ufree.request('wallet.antelope.signTransaction', [
  {
    account: 'eosio.token',
    name: 'transfer',
    authorization: [{ actor: 'alice', permission: 'active' }],
    data: { from: 'alice', to: 'bob', quantity: '1.0000 EOS', memo: 'tip' },
  },
]);

Roadmap: @ufree/wharfkit-plugin will ship later so Wharfkit-Session-based dApps integrate zero-touch. For now use the raw bridge.


9. Recommended: sign-in with wallet (replaces cookie sessions)

DApps inside the container cannot see cookies the user established in their regular browser tabs — browser-side cross-site storage partitioning blocks this. So traditional username/password + cookie session login does not work.

Use SIWE (Sign-In with Ethereum) or SIWN (Sign-In with Nostr) style flows:

async function loginWithUfree() {
  // 1. Fetch a one-time challenge from your backend
  const { challenge } = await fetch('/api/auth/challenge').then(r => r.json());

  // 2. Have the wallet sign it
  const [addr] = await window.ethereum.request({ method: 'eth_requestAccounts' });
  const sig = await window.ethereum.request({
    method: 'personal_sign',
    params: [challenge, addr],
  });

  // 3. Send addr+sig to backend, exchange for a session token
  const { token } = await fetch('/api/auth/verify', {
    method: 'POST',
    body: JSON.stringify({ address: addr, signature: sig, challenge }),
  }).then(r => r.json());

  localStorage.setItem('auth_token', token);
}

Backend verifies with ethers.verifyMessage(challenge, sig) === address — proves the user controls the private key for that address.

Same idea for Nostr: have the user sign a one-shot kind:27235 HTTP Auth event.


10. Container environment

Your dApp runs inside an <iframe sandbox="allow-scripts allow-forms allow-same-origin" allow="...">. Capability matrix:

✅ Works normally

  • Full DOM / CSS / JS
  • fetch to your own backend (configure CORS — the iframe’s origin is your domain)
  • WebSocket
  • Canvas / WebGL (games are fine)
  • Web Audio / video / audio
  • Web Workers
  • IndexedDB / localStorage / sessionStorage (partitioned to this container)
  • Permission-gated browser APIs (browser prompts user on first call):
    • navigator.mediaDevices.getUserMedia — camera / microphone
    • navigator.clipboard — clipboard read/write
    • navigator.geolocation — geolocation
    • Fullscreen API
    • Accelerometer / gyroscope
    • Payment Request API
  • Public chain RPCs / Nostr relays / IPFS gateways (CORS-friendly ones)

⚠️ Partial

  • Service Worker: registerable inside iframe but partition behavior is fiddly — avoid unless required.
  • Push Notification: limited in iframes — use Ufree’s main app notification channel (roadmap).
  • Third-party cookies: cookies the user set on other sites in regular tabs are invisible inside the container. Every “I’m already logged into Twitter” assumption requires fresh in-iframe login.

❌ Does not work

  • WebUSB / WebBluetooth / WebNFC / WebSerial — sandbox disallows all hardware buses
  • Top-level navigation — you can’t replace Ufree’s page (security feature, not a bug)
  • Nesting cross-origin iframesX-Frame-Options rejections happen as usual
  • Bypassing Safari ITP / Firefox TCP — user privacy rules stand

🚫 srcdoc-only extra constraints (only when you publish with bundleHash)

  • iframe origin is opaque nullall backend fetches CORS-reject
  • No cookies
  • dApps with backends must publish without bundleHash (Mode 2)

11. Error handling

Bridge errors have shape { code: number, message: string, data?: any }. Common codes:

Code Meaning What you do
4001 User rejected the request Show “Cancelled”, don’t retry
4200 Method not supported by Ufree Hide that UI entry
4900 Not yet implemented Tell user “current version doesn’t support this”
4901 Wrong key family (e.g. EVM op when user is on Antelope wallet) Prompt user to switch wallets
4902 Wallet locked / no active account Direct user back to Ufree to unlock
-32601 Method not found (bridge typo) Check method name
-32602 Invalid params Check param shape
-32603 Internal bridge error Report + investigate

Example:

try {
  await window.ethereum.request({ method: 'personal_sign', params: [msg, addr] });
} catch (err) {
  if (err.code === 4001) {
    toast('Signature cancelled');
  } else if (err.code === 4902) {
    toast('Unlock Ufree wallet first');
  } else {
    toast('Sign failed: ' + err.message);
  }
}

12. Debugging

DevTools inside Ufree

On desktop Chrome / Firefox, open DevTools while Ufree is the active page, then use the frame selector at the top of the console to pick your mini-app iframe. console.log and debugger work normally from there.

Mobile remote debugging

  • Android Chrome: USB-connect, visit chrome://inspect → find the Ufree PWA → Inspect → switch to your iframe context
  • iOS Safari: USB to Mac → Safari → Develop → device name → Ufree page → pick frame

Local testing (dev mode)

You can use http://localhost:5173/... as bundleUrl during development — Ufree’s dev build allows localhost. Production enforces https / ipfs.

Inspecting container storage

DevTools → Application → Storage → pick your frame. The partitioned localStorage / IndexedDB lives there. It’s different from what users see when they open your site directly in a regular tab.


13. Pre-publish checklist

  • [ ] Decided single-file vs multi-file (determines whether to ship bundleHash)
  • [ ] bundleUrl is reachable on HTTPS / IPFS (CORS header — Access-Control-Allow-Origin: * or whitelist the Ufree origin)
  • [ ] picture set, sensible size (64×64 or 128×128 recommended)
  • [ ] Tested inside the iframe:
    • [ ] SDK loaded (window.ufree is not undefined)
    • [ ] Core wallet methods work (eth_requestAccounts / personal_sign / etc.)
    • [ ] Backend API returns 200 if you have one
    • [ ] Error handling covers 4001 user-rejected
  • [ ] Not relying on third-party cookies, WebUSB/NFC, or Service Workers
  • [ ] Wallet-signature login implemented (if originally cookie-based)
  • [ ] Published manifest in Ufree and tested the full load flow

Appendix A — Raw bridge protocol (advanced)

If you don’t want the SDK (e.g. implementing an SDK in another language), talk to the host over postMessage directly.

Request (mini-app → host)

window.parent.postMessage({
  ufree: 1,
  kind: 'request',
  id: '<unique string>',
  method: 'wallet.evm.signMessage',
  params: { message: 'hello' },
}, '*');

Response (host → mini-app)

Received on the iframe’s window via message event:

{
  ufree: 1,
  kind: 'response',
  id: '<echoes the request id>',
  result?: <any>,
  error?: { code, message, data? },
}

Method list

app.getInfo                          → manifest info
app.close                            → close mini-app
nostr.getPublicKey                   → hex pubkey
nostr.signEvent(event)               → signed event
wallet.getType                       → 'evm' | 'antelope'
wallet.evm.getAddress                → 0x...
wallet.evm.getChainId                → number
wallet.evm.signMessage({message})    → 0x...sig
wallet.evm.sendTransaction(tx)       → ⏳ NOT_IMPLEMENTED
wallet.evm.switchChain({chainId})    → ⏳ NOT_IMPLEMENTED
wallet.antelope.getAccount           → account name
wallet.antelope.getChain             → 'eos' | 'eos-jungle4'
wallet.antelope.signTransaction([])  → ⏳ NOT_IMPLEMENTED
wallet.antelope.switchChain          → ⏳ NOT_IMPLEMENTED
wallet.antelope.getTableRows         → ⏳ NOT_IMPLEMENTED

Appendix B — Reference implementation

The full fixture lives at public/miniapp-fixture/index.html in the Ufree repo — a self-contained ~250-line HTML demonstrating every bridge method, SDK usage, and EIP-1193 calls. The production SDK source is at public/sdk/ufree.js.

Feedback

Hit an integration issue, disagree with the protocol, or your dApp type isn’t covered? Open an issue: github.com/kangfengyu/EOSphere-web.

Write a comment
No comments yet.