Ufree Mini-App Developer Guide
- Ufree Mini-App Developer Guide
- 1. Minimum viable mini-app (5 minutes)
- 2. How publishing works
- 3. Manifest schema
- 4. Bundle hosting: single-file vs multi-file
- 5. SDK
- 6. EVM integration
- 7. Nostr integration (NIP-07)
- 8. Antelope integration
- 9. Recommended: sign-in with wallet (replaces cookie sessions)
- 10. Container environment
- 11. Error handling
- 12. Debugging
- 13. Pre-publish checklist
- Appendix A — Raw bridge protocol (advanced)
- Appendix B — Reference implementation
- Feedback
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
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 thenullorigin - 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
fetchto 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 / microphonenavigator.clipboard— clipboard read/writenavigator.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 iframes —
X-Frame-Optionsrejections 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
null— all 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)
- [ ]
bundleUrlis reachable on HTTPS / IPFS (CORS header —Access-Control-Allow-Origin: *or whitelist the Ufree origin) - [ ]
pictureset, sensible size (64×64 or 128×128 recommended) - [ ] Tested inside the iframe:
- [ ] SDK loaded (
window.ufreeis 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
- [ ] SDK loaded (
- [ ] 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