Ufree Mini-App 开发者接入指南
Ufree Mini-App 开发者接入指南
本指南面向想把 dApp 发布成 Ufree 小程序的第三方开发者。读完后你应该能:
- 30 行代码内做出一个调钱包的 demo
- 决定自己的 dApp 用单文件 + hash 模式还是多文件 + 无 hash 模式
- 知道哪些 Web API 在容器内可用、哪些不可用、怎么绕
一、5 分钟最小示例
把下面这个文件传到任意 HTTPS 主机(或 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>
接下来在 Ufree app 里:Settings → Publish a mini-app → 填表单 → 发布。
任何 Ufree 用户都能在 Apps tab 搜你的 npub 看到这个 mini-app。
二、发布流程
小程序的“发布“本质是给 Nostr 发一条 kind:30078 事件(NIP-78 任意应用数据,d tag 取值 miniapp)。事件的 pubkey 就是 app id,content 是 JSON 序列化的 manifest。
发布方式两种:
方式 A — 用 Ufree 应用内发布(推荐)
进 Ufree → Settings → Publish a mini-app。表单 prefill 当前已发布的(如果有),填好后点 Publish。Ufree 用当前钱包的 sk 签名 + 推到你配的 relay。
方式 B — 用任意 Nostr 客户端 / 自有签名工具
如果你已经有 Nostr 后端管线(CI 自动发布等场景),直接发事件就行。事件结构:
{
"kind": 30078,
"tags": [["d", "miniapp"]],
"content": "{\"name\":\"My App\",\"version\":\"0.1.0\",\"bundleUrl\":\"https://my-app.com/index.html\",\"about\":\"...\"}"
}
注意:kind:30078 是 NIP-33 可替换可寻址事件——同一个 pubkey + 同一个 d tag 只保留最新一条。这意味着重新发布等于更新,没有“修改 vs 新建“的分支。
三、Manifest 字段
interface MiniAppManifest {
name: string; // REQUIRED 显示名
version: string; // REQUIRED 语义化版本,如 "1.2.0"
bundleUrl: string; // REQUIRED https:// 或 ipfs:// 的 entry HTML
about?: string; // 简短描述(Apps 卡片二行字)
picture?: string; // 图标 URL,https / ipfs / data:image/
bundleHash?: string; // OPTIONAL bundleUrl 内容的 sha256 hex(小写无 0x 前缀)
// npub 字段由容器从事件 pubkey 推断,无需自填
}
字段约束:
| 字段 | 校验 |
|---|---|
bundleUrl |
必须以 https:// 或 ipfs:// 开头(生产)。dev 模式也接受 http://localhost,方便调试 |
picture |
同上 + 可以是 data:image/...(适合小图标内联) |
bundleHash |
必须是 64 位小写 hex sha256;任何不匹配格式都被忽略 |
容器拒绝整个 manifest 的情况:name / version / bundleUrl 缺失或为空、bundleUrl 协议非法。
四、Bundle 托管:单文件 vs 多文件
这是发布前最重要的一个决策。
模式 1:单文件 + bundleHash(最强保护,最严格约束)
把整个 app 打包成一个自包含的 HTML 文件——CSS 内联、JS 内联、图标用 data URL。计算 sha256 后放进 bundleHash 字段。
容器加载时:
- fetch bundleUrl 拉回 HTML
- 重新计算 sha256,比对 manifest 里的
bundleHash - 一致:用
<iframe srcdoc=...>内联运行(整个执行字节是经过校验的) - 不一致:拒绝加载,显示 “Integrity check failed”
什么时候选这个:你的 app 是纯前端 + 调链/Nostr,没有后端 API,没有用户登录态。比如:链上数据看板、合约调用器、空投 claim 页、static NFT 浏览器。
约束:
- iframe 的 origin 是 opaque “null”,不能调任何后端 API(CORS 永远拒绝 null origin)。
- 不能用 cookie / localStorage 来跨会话持久化登录态(但 IndexedDB 在 iframe 内可用,分区到这个容器)。
- 不能用 Service Worker。
- 真的就是单文件——内嵌图片用 data URL 或 base64。CSS / JS 内联。
模式 2:多文件 + 不带 bundleHash(最宽松,工作量小)
发布 manifest 时不填bundleHash(Publish 表单里就别点 Verify 按钮)。
容器加载时:
- 直接
<iframe src="https://your-app.com/index.html"> - iframe 跑在你的真实 origin(如
https://your-app.com)下 - 相对路径资源、CSS、JS、API 调用全部正常工作
- 但没有完整性校验——你后端被黑改了 bundle,用户加载到的就是被改的版本
什么时候选这个:标准 SPA(Vite / Next.js / 任何打包出来 N 个文件的项目),有后端 API。基本就是“普通 Web 应用 + 钱包接入“。
约束:
- 没了 bundleHash 保护,依赖你自己的传输安全(HTTPS)+ 主机安全。
- 想加回部分完整性:在 entry HTML 里给每个
<script>/<link>加integrity="sha384-..."(标准 SRI),浏览器原生校验外部资源。
模式 3:多文件 + 带 bundleHash(中间档)
如果你的 entry HTML 是固定的、只有 entry 引用的 assets 会变(比如 CDN 上的 JS 带 hash 文件名),可以发 bundleHash 锁定 entry。容器会用 srcdoc 模式加载并注入 <base href="https://your-app.com/">,让相对路径解析回你的原域名。
⚠️ 注意:跟模式 1 一样,srcdoc 模式下 iframe origin 是 null,不能调你的后端 API。所以模式 3 只适合“entry HTML + 几个静态 JS/CSS“那种纯前端,依然不能有 cookie 后端。
五、SDK 集成
<script src="https://YOUR-UFREE-HOST/sdk/ufree.js"></script> 是唯一一行接入代码。
加完后你能拿到:
window.ufree.request(method, params) // 原始 bridge 调用
window.ufree.ethereum // EIP-1193 provider
window.ufree.nostr // NIP-07 风格 provider
// 自动别名(如果全局还没人占):
window.ethereum = window.ufree.ethereum
window.nostr = window.ufree.nostr
最后两个别名是关键——意味着已有的 ethers / wagmi / nostr-tools 代码不用改一行就能用。
六、EVM 接入
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' })],
// ...其他配置
});
// 用户连接钱包时 wagmi 自动找到 window.ethereum
6.4 支持的 EIP-1193 方法
| 方法 | 状态 | 说明 |
|---|---|---|
eth_requestAccounts |
✅ | 返回单个地址的数组 |
eth_accounts |
✅ | 同上 |
eth_chainId |
✅ | 返回 0x1(占位,待 PR-N 接入链注册表) |
net_version |
✅ | 返回链 ID 的十进制字符串 |
personal_sign |
✅ | EIP-191 签名,参数顺序 [message, address] |
eth_sign |
✅ | 旧版顺序 [address, message],内部等同 personal_sign |
eth_sendTransaction |
⏳ | 错误码 4900 “not yet wired” — PR-N 接入 viem/ethers RPC 后启用 |
wallet_switchEthereumChain |
⏳ | 同上 4900,需要链注册表 |
wallet_addEthereumChain |
❌ | 暂不支持 |
eth_call / eth_blockNumber / 其他 RPC 读 |
❌ | 暂不支持,请自行连 RPC |
不支持的方法返回 {code:4200, message:"Method X not supported by Ufree"}。
七、Nostr 接入(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 已带 id / sig / pubkey 字段
NIP-07 是 Nostr 圈事实标准,nostr-tools / nip07 / 各种社交客户端的 SDK 都直接用 window.nostr,零改造。
八、Antelope 接入
Antelope 没有 EIP-1193 这种全局标准,目前只能走原始 bridge:
// 查当前账户
const accountName = await window.ufree.request('wallet.antelope.getAccount');
// 查当前链('eos' / 'eos-jungle4')
const chain = await window.ufree.request('wallet.antelope.getChain');
// 签 + 推交易(PR-N 接入 Wharfkit 后启用,当前返回 4900)
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 会出,这样用 Wharfkit Session 的 dApp 也能零改造接入。当前请用上面的原始 bridge。
九、推荐:用钱包签名做无密码登录
dApp 在容器内拿不到用户在普通浏览器里建立的 cookie 登录态(浏览器跨站点 storage 分区)。所以传统“用户名密码登录 + cookie session“那套不工作。
推荐用钱包签名做 SIWE 风格(Sign-In with Ethereum)或 SIWN(Sign-In with Nostr):
// 前端
async function loginWithUfree() {
// 1. 找后端要个挑战字符串(防重放)
const { challenge } = await fetch('/api/auth/challenge').then(r => r.json());
// 2. 让钱包签名
const [addr] = await window.ethereum.request({ method: 'eth_requestAccounts' });
const sig = await window.ethereum.request({
method: 'personal_sign',
params: [challenge, addr],
});
// 3. 把地址 + 签名发给后端换 JWT / session token
const { token } = await fetch('/api/auth/verify', {
method: 'POST',
body: JSON.stringify({ address: addr, signature: sig, challenge }),
}).then(r => r.json());
// 4. 后续请求带这个 token
localStorage.setItem('auth_token', token);
}
后端用 ethers.verifyMessage(challenge, sig) === address 验签,确认用户持有这个地址的私钥。
Nostr 同理,用 nostr.signEvent 签一个一次性 kind 27235 事件(HTTP Auth)即可。
十、容器环境约束
你的 dApp 跑在一个 <iframe sandbox="allow-scripts allow-forms allow-same-origin" allow="..."> 里。下面是具体的能力清单:
✅ 完全可用
- 标准 DOM / CSS / JS 全套
fetch到你自己的后端(CORS 配好就行;iframe origin 是你的原域名)- WebSocket
- Canvas / WebGL(游戏没问题)
- Web Audio / 视频 / 音频
- Web Workers
- IndexedDB / localStorage / sessionStorage(分区到这个容器内)
- 通过
allow=委托的浏览器权限 API(用户首次调用时浏览器弹权限框):navigator.mediaDevices.getUserMedia— 摄像头 / 麦克风navigator.clipboard— 剪贴板读写navigator.geolocation— 定位- Fullscreen API
- 加速度计 / 陀螺仪
- Payment Request API
- 公开的链 RPC / Nostr relay / IPFS 网关(CORS 友好的那些)
⚠️ 部分受限
- Service Worker:iframe 内可注册,但分区行为复杂,离线策略容易踩坑。建议除非有强需求否则不用。
- Push Notification:iframe 内难以正常工作,建议通过 Ufree 主应用的通知通道(roadmap)。
- 第三方 cookie:用户在普通浏览器里登录其他网站留下的 cookie,在容器内看不见。所有“我之前在 Twitter 登过“那种登录态都需要重新登。
❌ 容器内基本不可用
- WebUSB / WebBluetooth / WebNFC / WebSerial:iframe 默认全禁,硬件钱包接不进来(不过用户已经有 Ufree 钱包了)
- Top-level navigation:你不能把 Ufree 的当前页面整个跳走(这是安全特性,不是 bug)
- 跨域 iframe 嵌套:你想再在自己 iframe 里嵌 YouTube 之类,被 X-Frame-Options 拒的还是拒
- 绕过 Safari ITP / Firefox TCP:用户隐私规则照常生效
🚫 srcdoc 模式额外限制(仅当你发了 bundleHash 时)
- iframe origin 是 opaque “null”,任何后端 fetch 的 CORS 都会拒绝
- 不能用 cookie
- 推论:有后端的 dApp 一定不要发 bundleHash,用模式 2
十一、错误处理
bridge 返回的错误结构:{ code: number, message: string, data?: any }。常见错误码:
| Code | 含义 | 你该怎么做 |
|---|---|---|
4001 |
用户拒绝了请求 | 提示“已取消“,不要重试 |
4200 |
方法不被 Ufree 支持(如 wallet_addEthereumChain) |
UI 上隐藏对应入口 |
4900 |
暂未实现(如 eth_sendTransaction 在接 RPC 前) |
提示用户当前版本不支持 |
4901 |
调用了错误链家族的方法(如 EVM 操作但用户当前是 Antelope 钱包) | 引导用户切到正确钱包 |
4902 |
钱包未解锁 / 未配置 | 引导用户先进 Ufree 解锁 |
-32601 |
方法不存在(bridge 协议错) | 检查方法名拼写 |
-32602 |
参数无效 | 检查参数格式 |
-32603 |
bridge 内部错误 | 用户报告 + 你检查 |
示例:
try {
await window.ethereum.request({ method: 'personal_sign', params: [msg, addr] });
} catch (err) {
if (err.code === 4001) {
toast('你取消了签名');
} else if (err.code === 4902) {
toast('请先在 Ufree 解锁钱包');
} else {
toast('签名失败:' + err.message);
}
}
十二、调试
在 Ufree 里打开 DevTools
桌面 Chrome / Firefox 进 Ufree,浏览器 DevTools 选到你的 mini-app iframe 上下文(DevTools console 顶部有个下拉菜单选 frame)。然后正常 console.log / debugger 都能用。
Mobile 远程调试
- Android Chrome:USB 连电脑,访问
chrome://inspect→ 看到 Ufree 的 PWA → Inspect → 在 iframe 上下文里调 - iOS Safari:手机连 Mac,Safari → Develop → 设备名 → Ufree 页面 → 选 frame
本地测 (dev 模式)
bundleUrl 可以填 http://localhost:5173/... 之类——Ufree 在开发模式下放行 localhost。生产环境强制 https / ipfs。
容器内 storage 怎么看
DevTools → Application → Storage → 选择对应 frame,你的分区版 localStorage / IndexedDB 都在那里。注意这跟用户在普通标签页打开你的网站时看到的是不同的存储空间。
十三、发布前 checklist
- [ ] 单文件还是多文件已定(决定要不要发 bundleHash)
- [ ]
bundleUrl在 https / ipfs 上能访问(CORS 头加上Access-Control-Allow-Origin: *或允许 Ufree 域) - [ ]
picture已设,尺寸合适(推荐 64×64 或 128×128) - [ ] 在 iframe 内已测:
- [ ] SDK 加载成功(
window.ufree不是 undefined) - [ ] 关键钱包方法走得通(
eth_requestAccounts/personal_sign等) - [ ] 后端 API 调用 200(如果有后端)
- [ ] 错误处理覆盖 4001 用户拒绝
- [ ] SDK 加载成功(
- [ ] 不依赖以下能力(容器不支持):第三方 cookie 登录态、WebUSB / NFC、Service Worker 强依赖
- [ ] 已实现钱包签名登录(如果原本用 cookie session)
- [ ] 在 Ufree 内发布 manifest 并测过完整加载流程
附录 A:原始 bridge 协议(高级)
如果你不想用 SDK(比如自己实现别的语言的 SDK),可以直接走 postMessage 协议。
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)
// 通过 window 上的 message 事件接收
{
ufree: 1,
kind: 'response',
id: '<echo the request id>',
result?: <any>,
error?: { code, message, data? },
}
方法清单
app.getInfo → manifest info
app.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
附录 B:参考实现
完整的 fixture 在 Ufree 仓库 public/miniapp-fixture/index.html——一个 ~250 行的自包含 HTML,展示了所有 bridge 方法 + SDK + EIP-1193 用法。生产 SDK 源码在 public/sdk/ufree.js。
反馈
发现接入问题、对协议有意见,或者你的 dApp 类型在指南里没覆盖到,提 issue:github.com/kangfengyu/EOSphere-web。
Looking for comments…
Searching Nostr relays. This may take a moment the first time this article is opened.
Looking for comments…
Searching Nostr relays. This may take a moment the first time this article is opened.