全部文档/做一个服务:注册、用户记录、钱包许可
身份与服务

做一个服务:注册、用户记录、钱包许可

基于 Beagle 身份做服务的完整服务端,代码可以直接贴:用 Beagle 登录来注册用户,把用户记录存在你自己的数据库,按用户在 Beagle 个人资料里绑定的钱包资产来授予访问。没有密码,你的网站上也不需要连接钱包。

2026 年 10 月

你要做的是什么

三部分,按这个顺序。每部分在服务端都只有几十行,用户从不交给你密码、助记词或私钥。

部分用户做什么你的服务端做什么
1. 注册点「用 Beagle 登录」,在 Beagle 里确认一次发 nonce,验签,发会话
2. 用户记录什么都不用做按 Beagle userid 写入用户,记下登录和行为
3. 许可在 Beagle 个人资料里绑定一次钱包从公开记录里读出绑定的钱包,验证明,查资产
为什么不在你的网站上「连接钱包」

每个网站各做一个连接钱包,就是每个网站各跑一套钱包流程、各存一张绑定表、各让用户再签一次。用 Beagle 的话,用户只绑定一次:钱包签一段写明他 Beagle 身份的消息,这个证明放在他公开的 beagles.eth 记录里。任何服务都能读,谁都不用再问。

Beagle Meet(snoopy.fi.chat、goji.fi.chat)在生产上就是这么做的:登录、用户记录、按钱包给房间设门槛。下面的代码是给你的服务新写的,你不需要安装或运行 Meet。

npm install express pg @decentnetwork/beagle-connect @decentnetwork/beagle-ui
# beagle-connect brings @decentnetwork/peer (server-side signature check).
# beagle-ui is only used for its React-free subpath @decentnetwork/beagle-ui/wallet-proof.

1. 用 Beagle 登录来注册

没有单独的注册。第一次验签通过就创建用户,之后每次登录都是更新。背景(几种入口、三种身份)见给你的应用接入 Beagle 登录。

浏览器端。 一个按钮。

import { signIn } from "@decentnetwork/beagle-connect";

document.querySelector("#signin").addEventListener("click", async () => {
  // Open the window inside the click. After an await, popup blockers eat it.
  const win = window.open("about:blank", "beagle-connect", "width=420,height=560");

  // Single-use nonce from YOUR server. That is the whole replay defence.
  const { nonce } = await fetch("/api/auth/nonce", { credentials: "include" }).then((r) => r.json());

  // Finds the installed Beagle (127.0.0.1:8766) or falls back to app.beagle.chat.
  const who = await signIn({ nonce, existingWindow: win });
  // { userid, address, name, avatar, sig, nonce, uiOrigin }

  const r = await fetch("/api/auth/beagle", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ ...who, origin: location.origin }),
  });
  const out = await r.json();
  if (!r.ok) throw new Error(out.error);
  render(out.user, out.permit);   // permit: { ok, code?, message? }
});

服务端。 nonce、验签、会话,一个文件。@decentnetwork/peer 里的 verifyDetached 是 XEdDSA:Beagle userid 是 X25519 公钥,所以 nacl.sign 这类 Ed25519 库永远验不过。

import express from "express";
import { randomBytes, createHmac, timingSafeEqual } from "node:crypto";
import { verifyDetached, base58ToBytes } from "@decentnetwork/peer";

const app = express();
app.use(express.json());

// Exact origins your pages are served from. Never app.beagle.chat:
// the user signs YOUR origin.
const ORIGINS = new Set(["https://example.com"]);
const SECRET = process.env.SESSION_SECRET; // 32+ random bytes, not in git

// ── nonce: single-use, two minutes ──────────────────────────────
const nonces = new Map(); // nonce -> expiry. Use Redis if you run more than one process.

app.get("/api/auth/nonce", (_req, res) => {
  const now = Date.now();
  for (const [k, exp] of nonces) if (exp < now) nonces.delete(k);
  const nonce = randomBytes(18).toString("base64url");
  nonces.set(nonce, now + 120_000);
  res.json({ nonce });
});

function takeNonce(nonce) {
  const exp = nonces.get(nonce);
  nonces.delete(nonce);
  return exp !== undefined && exp >= Date.now();
}

// ── the one primitive: XEdDSA over the Beagle key, NOT Ed25519 ──
function verifyBeagle(prefix, userid, origin, salt, sigHex) {
  try {
    const msg = new TextEncoder().encode(`${prefix}\n${origin}\n${salt}`);
    return verifyDetached(base58ToBytes(userid), msg, Uint8Array.from(Buffer.from(sigHex, "hex")));
  } catch {
    return false;
  }
}

// ── session: an HMAC'd cookie keyed by the Beagle userid ────────
const mac = (v) => createHmac("sha256", SECRET).update(v).digest("hex");

function setSession(res, userid) {
  res.setHeader("Set-Cookie",
    `sid=${encodeURIComponent(`${userid}.${mac(userid)}`)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=2592000`);
}

function sessionUser(req) {
  const m = /(?:^|;\s*)sid=([^;]+)/.exec(req.headers.cookie || "");
  if (!m) return null;
  const v = decodeURIComponent(m[1]);
  const i = v.lastIndexOf(".");
  const userid = v.slice(0, i);
  const got = Buffer.from(v.slice(i + 1));
  const want = Buffer.from(mac(userid));
  return got.length === want.length && timingSafeEqual(got, want) ? userid : null;
}

// ── sign up / sign in: the same route ───────────────────────────
app.post("/api/auth/beagle", async (req, res) => {
  const { userid, nonce, sig, origin } = req.body ?? {};
  if (!userid || !nonce || !sig || !origin) return res.status(400).json({ error: "missing fields" });
  if (!ORIGINS.has(origin)) return res.status(400).json({ error: "origin not allowed" });
  if (!takeNonce(nonce)) return res.status(401).json({ error: "bad or expired nonce" });
  if (!verifyBeagle("decent-auth", userid, origin, nonce, sig))
    return res.status(401).json({ error: "signature did not verify" });

  const denied = await deniedReason(userid);
  if (denied) return res.status(403).json({ error: denied, code: "denied" });

  const user = await rememberUser(req, userid, req.body, "popup");
  setSession(res, userid);
  res.json({ user, permit: await permitFor(userid) });
});

从 Beagle 里打开。 如果你的服务在 Beagle 的「应用」页里,用户打开时已经登录了。页面加载时读 URL 片段,发到第二个路由。

import { readLaunch } from "@decentnetwork/beagle-connect";

// On page load. null = nothing in the fragment. Throws on a bad or stale one:
// show that, do not treat it as signed out.
const who = await readLaunch();
if (who) {
  await fetch("/api/auth/beagle-launch", {
    method: "POST",
    credentials: "include",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(who),
  });
}
// Opened from Beagle's Apps tab: already signed in, never ask again.
// No nonce you issued, so YOU bound freshness and refuse a repeat.
const usedLaunch = new Map(); // sig -> expiry

app.post("/api/auth/beagle-launch", async (req, res) => {
  const { userid, sig, ts } = req.body ?? {};
  // The Origin HEADER, not the body: with no nonce to bind, a body origin
  // would let an assertion minted for one of your sites be spent at another.
  const origin = req.get("origin");
  if (!userid || !sig || !Number.isFinite(Number(ts))) return res.status(400).json({ error: "missing fields" });
  if (!ORIGINS.has(origin)) return res.status(400).json({ error: "origin not allowed" });
  if (Math.abs(Date.now() - Number(ts)) > 120_000) return res.status(401).json({ error: "launch assertion expired" });
  if (!verifyBeagle("decent-launch", userid, origin, ts, sig))
    return res.status(401).json({ error: "signature did not verify" });
  const now = Date.now();
  for (const [k, exp] of usedLaunch) if (exp < now) usedLaunch.delete(k);
  if (usedLaunch.has(sig)) return res.status(401).json({ error: "launch assertion already used" });
  usedLaunch.set(sig, now + 240_000);

  const user = await rememberUser(req, userid, req.body, "launch");
  setSession(res, userid);
  res.json({ user, permit: await permitFor(userid) });
});
最容易做错的地方

拿主机名而不是完整 origin(https://example.com)去验签。nonce 重复用,或在客户端生成。该验 decent-auth 的地方接受了 decent-launch。把弹窗返回的 name、avatar、address 当成证明:它们没有签名。

2. 用户记录存在你自己的后端

Beagle userid 就是账号主键。存他给你看的资料,和他在你这里做过的事。他的私钥留在 Beagle 里,你永远看不到。有了这份记录,你才能奖励用户、之后联系到他、以及拒绝服务。

-- The account key is the Beagle userid. No password column, no key column.
CREATE TABLE users (
  userid        TEXT PRIMARY KEY,          -- Beagle userid (base58 X25519)
  name          TEXT,                      -- display only
  avatar_url    TEXT,                      -- display only
  carrier_addr  TEXT,                      -- where to reach them: message, add friend, reward
  beagles_name  TEXT,                      -- foo.beagles.eth, when they registered one
  first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  last_seen_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Every sign-in: which door, from where. The trail you reward or refuse on.
CREATE TABLE auth_events (
  id         BIGSERIAL PRIMARY KEY,
  at         TIMESTAMPTZ NOT NULL DEFAULT now(),
  userid     TEXT NOT NULL,
  door       TEXT NOT NULL,                -- popup | launch
  origin     TEXT,
  ip         TEXT,
  user_agent TEXT
);

-- What they did in YOUR service. One row per action you care about.
CREATE TABLE activity (
  id     BIGSERIAL PRIMARY KEY,
  at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  userid TEXT NOT NULL,
  kind   TEXT NOT NULL,                    -- e.g. joined, viewed_signal, posted
  detail JSONB
);

-- Refusing service is a row, written before you need it.
CREATE TABLE denials (
  userid    TEXT PRIMARY KEY,
  reason    TEXT NOT NULL,
  by_userid TEXT,
  at        TIMESTAMPTZ NOT NULL DEFAULT now()
);

用户有 beagles.eth 记录时,用它来填资料。它是签过名、公开发布的,不像弹窗里那些提示字段。

import pg from "pg";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const GATEWAY = "https://ens-gateway.beaglechat.workers.dev";

// The person's public beagles.eth record, or null. Read-only, no key needed.
async function beaglesRecord(userid) {
  try {
    const r = await fetch(`${GATEWAY}/getAddress/${encodeURIComponent(userid)}`,
      { signal: AbortSignal.timeout(2500) });
    if (!r.ok) return null;
    const rec = await r.json();
    // It must be THIS person's record: written by this key, or naming it as
    // carrierUserId (names registered from the mobile wallet are owned by the wallet).
    return rec && (rec.owner === userid || rec.texts?.carrierUserId === userid) ? rec : null;
  } catch {
    return null;
  }
}

const clip = (v, n) => (typeof v === "string" && v.trim() ? v.trim().slice(0, n) : null);

async function rememberUser(req, userid, body, door) {
  const rec = await beaglesRecord(userid);
  // The published record wins; the popup's name/avatar/address are unsigned hints.
  const name = clip(rec?.texts?.displayName, 64) ?? clip(body.name, 64);
  const avatar = clip(rec?.texts?.avatar, 512) ?? clip(body.avatar, 512);
  const carrier = clip(rec?.texts?.carrierAddress, 64) ?? clip(body.address, 64);

  const { rows } = await db.query(
    `INSERT INTO users (userid, name, avatar_url, carrier_addr, beagles_name)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (userid) DO UPDATE SET
       name         = COALESCE(EXCLUDED.name, users.name),
       avatar_url   = COALESCE(EXCLUDED.avatar_url, users.avatar_url),
       carrier_addr = COALESCE(EXCLUDED.carrier_addr, users.carrier_addr),
       beagles_name = COALESCE(EXCLUDED.beagles_name, users.beagles_name),
       last_seen_at = now()
     RETURNING *`,
    [userid, name, avatar, carrier, rec?.name ?? null],
  );
  await db.query(
    `INSERT INTO auth_events (userid, door, origin, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
    [userid, door, req.get("origin") ?? null, req.ip, clip(req.get("user-agent"), 256)],
  );
  return rows[0];
}

async function deniedReason(userid) {
  const { rows } = await db.query(`SELECT reason FROM denials WHERE userid = $1`, [userid]);
  return rows[0]?.reason ?? null;
}

const track = (userid, kind, detail = null) =>
  db.query(`INSERT INTO activity (userid, kind, detail) VALUES ($1, $2, $3)`, [userid, kind, detail]);
存绝不存
userid、名字、头像、Carrier 地址、beagles.eth 名字密码:本来就没有
每次登录:入口、origin、IP、时间私钥、助记词、钱包密钥
做过什么:加入、查看、购买、发帖把弹窗的名字/头像当身份
拒绝记录:原因、谁决定的
Carrier 地址是你联系他的方式

留着 carrier_addr。有了它,你的服务或它的 agent 可以通过 Beagle 网络给这个人发消息、加好友、发奖励,而不持有他的任何东西。

3. 按钱包资产授予访问

用户在 Beagle 里绑定一次钱包:个人资料 > beagles.eth > 绑定 Solana / Ethereum。他的钱包(Phantom、MetaMask)签一段写明 Beagle userid、链、地址和时间的消息,Beagle 把地址和这个签名写进他公开的记录。你读它不需要任何密钥,也不用再弹窗。

GET https://ens-gateway.beaglechat.workers.dev/getAddress/<userid>

{
  "name": "alice.beagles.eth",
  "owner": "<userid>",
  "addresses": { "501": "<solana address>" },          // 60 = ETH, 501 = SOL
  "texts": {
    "displayName": "Alice",
    "avatar": "https://…",
    "carrierAddress": "<52-char Carrier address>",
    "carrierUserId": "<userid>",
    "proof.sol": "{\"v\":1,\"msg\":\"…userid: <userid>\\nchain: sol\\naddress: …\\nissued: …\",\"sig\":\"…\"}"
  }
}

verifyRecordWallet(record, "sol") 离线验证这个签名,返回地址,或者告诉你为什么不行。用 Beagle 手机钱包注册的名字(记录本身归这个钱包所有)它也认。然后按你平常的办法查这个地址上的资产。

import { verifyRecordWallet } from "@decentnetwork/beagle-ui/wallet-proof";

const RPC = process.env.SOLANA_RPC || "https://api.mainnet-beta.solana.com";
const MINT = "YourTokenMint11111111111111111111111111111";  // your SPL token mint
const THRESHOLD = 1000;                                     // whole tokens

// Sum of one SPL token across every token account the wallet owns.
async function splBalance(owner, mint) {
  const r = await fetch(RPC, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    signal: AbortSignal.timeout(3000),
    body: JSON.stringify({
      jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner",
      params: [owner, { mint }, { encoding: "jsonParsed" }],
    }),
  });
  const { result, error } = await r.json();
  if (error) throw new Error(error.message);
  return result.value.reduce((sum, a) => sum + (a.account.data.parsed.info.tokenAmount.uiAmount ?? 0), 0);
}

const yes = new Map(); // userid -> time of the last yes

// { ok: true } | { ok: false, code, message } | { ok: null, code: "unavailable", message }
async function permitFor(userid) {
  const at = yes.get(userid);
  if (at && Date.now() - at < 60_000) return { ok: true };

  const rec = await beaglesRecord(userid);
  const wallet = rec ? verifyRecordWallet(rec, "sol") : { ok: false };
  if (!wallet.ok) {
    return { ok: false, code: "no-wallet",
      message: "Bind a Solana wallet in your Beagle profile (Profile > beagles.eth), then sign in again." };
  }

  let balance;
  try {
    balance = await splBalance(wallet.address, MINT);
  } catch {
    // The chain read failed. That is not a no: say so, and decide your policy.
    return { ok: null, code: "unavailable", message: "Could not check your wallet right now. Try again in a minute." };
  }
  if (balance < THRESHOLD) {
    return { ok: false, code: "below-threshold", message: `Holding ${THRESHOLD} tokens unlocks this.` };
  }
  yes.set(userid, Date.now()); // cache a yes briefly; never cache a no for long
  return { ok: true };
}

// Put it in front of anything that needs the permit.
async function requirePermit(req, res, next) {
  const userid = sessionUser(req);
  if (!userid) return res.status(401).json({ error: "Sign in with Beagle first" });
  if (await deniedReason(userid)) return res.status(403).json({ error: "denied", code: "denied" });
  const p = await permitFor(userid);
  if (p.ok !== true) return res.status(p.ok === null ? 503 : 403).json(p);
  req.userid = userid;
  next();
}

app.get("/api/signals", requirePermit, async (req, res) => {
  await track(req.userid, "viewed_signals");
  res.json(await latestSignals());
});

以太坊是同一份记录、同一个调用,参数换成 "eth"。

// Same record, other chain. Proof: EIP-191 personal_sign by that address.
const w = verifyRecordWallet(rec, "eth");   // { ok, address: "0x…", via }
// ERC-20 balanceOf(address) through eth_call on your RPC:
const data = "0x70a08231" + w.address.slice(2).toLowerCase().padStart(64, "0");
// POST { jsonrpc: "2.0", id: 1, method: "eth_call", params: [{ to: TOKEN, data }, "latest"] }
// → hex balance in the token's smallest unit; divide by 10 ** decimals.
链上读取失败不等于拒绝

RPC 超时就返回「暂时查不了」(ok: null,HTTP 503),绝不说「你不符合条件」。故障期间放不放人是你的策略,要有意识地决定。「是」可以缓存一分钟左右;「否」别缓存太久,不然刚买入的人会白等。

证明的是钱包归谁,不是里面有什么

签名证明的是这个 Beagle 身份控制这个地址。余额每个区块都在变,所以在授予访问的那一刻读,许可过期时再读一次。

上线前检查

01
nonce 由服务端发,只能用一次,约两分钟有效

多进程部署就放 Redis 或数据库。

02
ORIGINS 写准你自己的 origin

协议加主机,和浏览器里的 location.origin 完全一致。

03
SESSION_SECRET 随机生成,不进 git

换掉它会让所有人重新登录,本来就该这样。

04
用户表以 Beagle userid 为主键

有 beagles.eth 记录就用记录;弹窗字段只做兜底。

05
上线前就有拒绝通道

denials 里一行就能挡住登录和所有许可。

06
许可读绑定的钱包,不做连接钱包

没绑钱包就用一句话告诉用户去 Beagle 哪里绑。

07
「查不了」是单独的结果

返回 503 和重试提示,不是 403。

下一篇Beagle 到底是什么 →