@decentnetwork/beagle 0.1.117 → 0.1.119

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- window.__DK_UI_VERSION="0.1.117";
1
+ window.__DK_UI_VERSION="0.1.119";
2
2
  "use strict";
3
3
  (() => {
4
4
  // packages/beagle-ui/dist/beagle-ui.js
@@ -123,6 +123,8 @@ window.__DK_UI_VERSION="0.1.117";
123
123
  var hostPrefersRtcFile = false;
124
124
  var hostHasKeyBackup = false;
125
125
  var hostEnsWrite = true;
126
+ var hostWalletBind = true;
127
+ var hostAssistant = true;
126
128
  var hostBrandHref = "";
127
129
  var hostTabs = null;
128
130
  var hostHideApps = null;
@@ -142,6 +144,8 @@ window.__DK_UI_VERSION="0.1.117";
142
144
  hostPrefersRtcFile = !!H.hostPrefersRtcFile;
143
145
  hostHasKeyBackup = !!H.hostHasKeyBackup;
144
146
  hostEnsWrite = H.hostEnsWrite !== void 0 ? !!H.hostEnsWrite : !H.hostNeedsWelcome;
147
+ hostWalletBind = H.hostWalletBind !== void 0 ? !!H.hostWalletBind : hostEnsWrite;
148
+ hostAssistant = H.hostAssistant !== void 0 ? !!H.hostAssistant : true;
145
149
  hostBrandHref = typeof H.hostBrandHref === "string" ? H.hostBrandHref : "";
146
150
  }
147
151
  function fileUrl(name) {
@@ -1340,7 +1344,9 @@ window.__DK_UI_VERSION="0.1.117";
1340
1344
  userId: ASSISTANT_ID,
1341
1345
  alias: "Beagle Assistant",
1342
1346
  address: "",
1343
- online: true,
1347
+ // Not a peer, so not "online": there is no session, nothing to be present
1348
+ // on, and no dot is drawn for an `app` row anywhere in the UI.
1349
+ online: false,
1344
1350
  app: true,
1345
1351
  agent: false,
1346
1352
  pending: false,
@@ -1782,7 +1788,7 @@ ${lines}`, { assistant: { kind: "adopt", rows: fresh } }));
1782
1788
  textOverflow: "ellipsis",
1783
1789
  flex: 1,
1784
1790
  minWidth: 0
1785
- } }, dkRowPreview(row.preview)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, row.lastTime), row.unread ? /* @__PURE__ */ React.createElement(Unread, { n: row.unread }) : peer.pending ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--warn, #d29922)", title: "friend request pending" }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
1791
+ } }, dkRowPreview(row.preview)))), /* @__PURE__ */ React.createElement("div", { style: { display: "flex", flexDirection: "column", alignItems: "flex-end", gap: 4, flexShrink: 0 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 10.5, color: "var(--faint)" } }, row.lastTime), peer.app ? null : row.unread ? /* @__PURE__ */ React.createElement(Unread, { n: row.unread }) : peer.pending ? /* @__PURE__ */ React.createElement(Icon, { name: "clock", size: 11, stroke: 2.2, color: "var(--warn, #d29922)", title: "friend request pending" }) : /* @__PURE__ */ React.createElement(StatusDot, { online: peer.online })));
1786
1792
  }
1787
1793
  function dkActivityTs(p, isProtocol, fix) {
1788
1794
  const row = rosterRow(p, isProtocol || (() => false), fix);
@@ -3482,6 +3488,65 @@ ${peer.address}`
3482
3488
  return /* @__PURE__ */ React.createElement("div", { style: { flex: 1, display: "flex", minWidth: 0, minHeight: 0 } }, /* @__PURE__ */ React.createElement(PeerSidebar, { T, peers, requests, activeId, onSelect, onAct, onAdd, prefillAddr, onPrefillConsumed, muted, isProtocol, protocolFix }), peer ? /* @__PURE__ */ React.createElement(Conversation, { T, peer, lang, peers, onOpenChat: onSelect, thread, isProtocol, onSend, onSendFile, onSendRtcFile, onAlias, onRemove, onOpenNet, onCall, onReloadThread, hasEarlier, onLoadEarlier, onAssistantRemove, onAssistantAdopt, muted: !!muted && muted.includes(peer.id), onToggleMute }) : /* @__PURE__ */ React.createElement(ChatEmpty, { T }));
3483
3489
  }
3484
3490
  Object.assign(window, { ChatTab });
3491
+ var PROOF_KEY = { eth: "proof.eth", sol: "proof.sol" };
3492
+ var HEAD = "Beagle wallet binding";
3493
+ var LINE = "I bind this wallet to my Beagle identity.";
3494
+ var ADDRESS_RE = { eth: /^0x[0-9a-fA-F]{40}$/, sol: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ };
3495
+ var USERID_RE = /^[1-9A-HJ-NP-Za-km-z]{32,48}$/;
3496
+ var ISSUED_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
3497
+ function isWalletAddress(chain, address) {
3498
+ const re = ADDRESS_RE[chain];
3499
+ return !!re && typeof address === "string" && re.test(address);
3500
+ }
3501
+ function proofTime(date = /* @__PURE__ */ new Date()) {
3502
+ return new Date(Math.floor(date.getTime() / 1e3) * 1e3).toISOString().replace(".000Z", "Z");
3503
+ }
3504
+ function walletProofMessage({ userid, chain, address, issued }) {
3505
+ if (!USERID_RE.test(String(userid || "")))
3506
+ throw new Error("wallet proof: bad userid");
3507
+ if (!isWalletAddress(chain, address))
3508
+ throw new Error("wallet proof: bad chain or address");
3509
+ if (!ISSUED_RE.test(String(issued || "")))
3510
+ throw new Error("wallet proof: bad issued time");
3511
+ return [HEAD, "", LINE, "", `userid: ${userid}`, `chain: ${chain}`, `address: ${address}`, `issued: ${issued}`].join("\n");
3512
+ }
3513
+ var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
3514
+ function base58Encode(bytes) {
3515
+ const digits = [];
3516
+ for (const b of bytes) {
3517
+ let carry = b;
3518
+ for (let j = 0; j < digits.length; j++) {
3519
+ carry += digits[j] << 8;
3520
+ digits[j] = carry % 58;
3521
+ carry = carry / 58 | 0;
3522
+ }
3523
+ while (carry > 0) {
3524
+ digits.push(carry % 58);
3525
+ carry = carry / 58 | 0;
3526
+ }
3527
+ }
3528
+ let out = "";
3529
+ for (const b of bytes) {
3530
+ if (b !== 0)
3531
+ break;
3532
+ out += "1";
3533
+ }
3534
+ for (let i = digits.length - 1; i >= 0; i--)
3535
+ out += B58[digits[i]];
3536
+ return out;
3537
+ }
3538
+ var utf8Hex = (s) => "0x" + Array.from(new TextEncoder().encode(s), (b) => b.toString(16).padStart(2, "0")).join("");
3539
+ async function signEthProof(provider, { userid, address, issued = proofTime() }) {
3540
+ const message = walletProofMessage({ userid, chain: "eth", address, issued });
3541
+ const signature = await provider.request({ method: "personal_sign", params: [utf8Hex(message), address] });
3542
+ return { message, signature: String(signature) };
3543
+ }
3544
+ async function signSolProof(wallet, { userid, address, issued = proofTime() }) {
3545
+ const message = walletProofMessage({ userid, chain: "sol", address, issued });
3546
+ const r = await wallet.signMessage(new TextEncoder().encode(message), "utf8");
3547
+ const sig = r && r.signature ? r.signature : r;
3548
+ return { message, signature: base58Encode(Uint8Array.from(sig)) };
3549
+ }
3485
3550
  function CopyBtn({ value, copiedText = "Copied", copyFailedText = "Copy failed", copyTitle = "Copy" }) {
3486
3551
  const [status, setStatus] = React.useState(null);
3487
3552
  const timer = React.useRef(null);
@@ -3895,7 +3960,7 @@ ${peer.address}`
3895
3960
  const [msg, setMsg] = React.useState(null);
3896
3961
  const reload = () => {
3897
3962
  fetch("/api/ens-name").then((r) => r.json()).then((d) => {
3898
- setSt({ loading: false, registered: !!d.registered, record: d.record || null, mineOwned: !!d.mineOwned });
3963
+ setSt({ loading: false, registered: !!d.registered, record: d.record || null, mineOwned: !!d.mineOwned, writeBlocked: d.writeBlocked || "" });
3899
3964
  if (d.registered && d.record && d.record.name)
3900
3965
  setLabel(d.record.name.replace(/\.beagles\.eth$/i, ""));
3901
3966
  if (onRecord)
@@ -3922,7 +3987,15 @@ ${peer.address}`
3922
3987
  if (l)
3923
3988
  post("/api/ens-register", { name: l, displayName: me.name, description: me.description || "" });
3924
3989
  };
3925
- const bind = (chain, address) => post("/api/ens-bind-wallet", { chain, address });
3990
+ const bind = (chain, address, proof) => post("/api/ens-bind-wallet", proof ? { chain, address, proof } : { chain, address });
3991
+ const signing = () => {
3992
+ setBusy(true);
3993
+ setMsg({ tone: "ok", text: T.ensSigning || "approve the signature in your wallet\u2026" });
3994
+ };
3995
+ const walletErr = (e) => {
3996
+ setBusy(false);
3997
+ setMsg({ tone: "err", text: String(e && e.message || e) });
3998
+ };
3926
3999
  const bindEth = async () => {
3927
4000
  const eth = window.ethereum;
3928
4001
  if (!eth) {
@@ -3931,10 +4004,13 @@ ${peer.address}`
3931
4004
  }
3932
4005
  try {
3933
4006
  const a = await eth.request({ method: "eth_requestAccounts" });
3934
- if (a && a[0])
3935
- bind("eth", a[0]);
4007
+ if (!a || !a[0])
4008
+ return;
4009
+ signing();
4010
+ const proof = await signEthProof(eth, { userid: me.userId, address: a[0] });
4011
+ bind("eth", a[0], proof);
3936
4012
  } catch (e) {
3937
- setMsg({ tone: "err", text: String(e && e.message || e) });
4013
+ walletErr(e);
3938
4014
  }
3939
4015
  };
3940
4016
  const bindSol = async () => {
@@ -3946,10 +4022,13 @@ ${peer.address}`
3946
4022
  try {
3947
4023
  const r = await sol.connect();
3948
4024
  const a = r && r.publicKey && r.publicKey.toString();
3949
- if (a)
3950
- bind("sol", a);
4025
+ if (!a)
4026
+ return;
4027
+ signing();
4028
+ const proof = await signSolProof(sol, { userid: me.userId, address: a });
4029
+ bind("sol", a, proof);
3951
4030
  } catch (e) {
3952
- setMsg({ tone: "err", text: String(e && e.message || e) });
4031
+ walletErr(e);
3953
4032
  }
3954
4033
  };
3955
4034
  const field = { flex: 1, minWidth: 0, height: 34, borderRadius: 8, border: "1px solid var(--line)", background: "var(--panel-2)", color: "var(--text)", fontFamily: "var(--mono)", fontSize: 13, padding: "0 10px", outline: "none" };
@@ -3998,6 +4077,7 @@ ${peer.address}`
3998
4077
  const [picking, setPicking] = React.useState(false);
3999
4078
  const fileRef = React.useRef(null);
4000
4079
  const canWrite = hostEnsWrite && !!st.mineOwned;
4080
+ const canBind = hostWalletBind && !!st.mineOwned && !st.writeBlocked;
4001
4081
  const rec = st.record;
4002
4082
  const boundEth = rec && rec.addresses && rec.addresses["60"];
4003
4083
  const boundSol = rec && rec.addresses && rec.addresses["501"];
@@ -4020,7 +4100,16 @@ ${peer.address}`
4020
4100
  setMsg({ tone: "err", text: m === "too-big" ? T.ensTooBig || "image too large even after scaling" : m === "bad-image" ? T.ensBadImage || "cannot read that image" : String(m || err) });
4021
4101
  });
4022
4102
  };
4023
- const walletRow = (lbl, chain, bound, onBind, last) => /* @__PURE__ */ React.createElement("div", { className: "dk-row", style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { className: "dk-lbl", style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, lbl), bound ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, bound), /* @__PURE__ */ React.createElement(CopyBtn, { value: bound, copiedText: T.copied, copyFailedText: T.copyFailed, copyTitle: T.copy }), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !canWrite, onClick: () => bind(chain, "") }, T.ensUnbind || "unbind")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotBound || "not bound"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canWrite, onClick: onBind }, T.ensBind || "bind")));
4103
+ const proven = (chain, bound) => {
4104
+ if (rec && rec.owner && String(rec.owner).toLowerCase() === String(bound).toLowerCase())
4105
+ return true;
4106
+ try {
4107
+ return JSON.parse((rec && rec.texts || {})[PROOF_KEY[chain]]).msg.indexOf("address: " + bound + "\n") >= 0;
4108
+ } catch {
4109
+ return false;
4110
+ }
4111
+ };
4112
+ const walletRow = (lbl, chain, bound, onBind, last) => /* @__PURE__ */ React.createElement("div", { className: "dk-row", style: { ...row, borderBottom: last ? "none" : row.borderBottom } }, /* @__PURE__ */ React.createElement("span", { className: "dk-lbl", style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, lbl), bound ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 2 } }, /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, bound), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--ui)", fontSize: 11, color: proven(chain, bound) ? "var(--online)" : "var(--faint)" } }, proven(chain, bound) ? T.ensProven || "signed by this wallet" : T.ensUnproven || "no wallet signature yet")), /* @__PURE__ */ React.createElement(CopyBtn, { value: bound, copiedText: T.copied, copyFailedText: T.copyFailed, copyTitle: T.copy }), !proven(chain, bound) && /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canBind, onClick: onBind }, T.ensProve || "sign"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !canBind, onClick: () => bind(chain, "") }, T.ensUnbind || "unbind")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotBound || "not bound"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canBind, onClick: onBind }, T.ensBind || "bind")));
4024
4113
  return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Card, { label: T.ensCard || "Name \xB7 beagles.eth" }, st.loading ? /* @__PURE__ */ React.createElement("div", { style: { padding: "14px 16px", fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--faint)" } }, T.dirLoading || "loading\u2026") : /* @__PURE__ */ React.createElement(React.Fragment, null, !hostEnsWrite ? /* @__PURE__ */ React.createElement("div", { className: "dk-row dk-wrap", style: row }, /* @__PURE__ */ React.createElement("span", { className: "dk-lbl", style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensName || "name"), st.registered && rec && rec.name ? /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 13, color: "var(--text)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, rec.name) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--ui)", fontSize: 12, lineHeight: 1.5, color: "var(--faint)" } }, T.ensWebOnly || "Registering a beagles.eth name needs the Beagle phone or desktop app. Your avatar can be set above without one.")) : /* @__PURE__ */ React.createElement("div", { className: "dk-row dk-wrap", style: row }, /* @__PURE__ */ React.createElement("span", { className: "dk-lbl", style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensName || "name"), /* @__PURE__ */ React.createElement(
4025
4114
  "input",
4026
4115
  {
@@ -4034,7 +4123,7 @@ ${peer.address}`
4034
4123
  },
4035
4124
  style: { ...field, minWidth: 110 }
4036
4125
  }
4037
- ), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { className: "dk-row dk-wrap", style: row }, /* @__PURE__ */ React.createElement("span", { className: "dk-lbl", style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), upAvatar ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 34, height: 34, style: { width: 34, height: 34, borderRadius: 9, objectFit: "cover", flexShrink: 0, background: "var(--panel-2)" } }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--text)" } }, T.ensCustom || "custom image"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !canWrite, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !canWrite, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canWrite, onClick: () => fileRef.current && fileRef.current.click() }, T.ensUpload || "upload"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canWrite, onClick: () => setPicking(true) }, T.ensChoose || "choose"), /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", accept: "image/*", onChange: onUploadFile, style: { display: "none" } })), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) })), !st.loading && st.registered && /* @__PURE__ */ React.createElement(Card, { label: T.linksCard || "Links" }, SOCIALS.map((s, i) => socialRow(s, i === SOCIALS.length - 1))));
4126
+ ), /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--dim)", flexShrink: 0 } }, ".beagles.eth"), /* @__PURE__ */ React.createElement(Btn, { tone: "accent", size: "sm", disabled: busy || !label.trim(), onClick: register }, busy ? "\u2026" : st.registered && st.mineOwned ? T.ensUpdate || "update" : T.ensRegister || "register")), st.registered && !st.mineOwned && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: "1px solid var(--line)", fontFamily: "var(--ui)", fontSize: 11.5, color: "var(--faint)" } }, (T.ensWalletOwned || "registered via your mobile wallet as") + " ", /* @__PURE__ */ React.createElement("span", { style: { fontFamily: "var(--mono)" } }, rec && rec.name)), st.registered && /* @__PURE__ */ React.createElement("div", { className: "dk-row dk-wrap", style: row }, /* @__PURE__ */ React.createElement("span", { className: "dk-lbl", style: { width: 130, flexShrink: 0, fontFamily: "var(--mono)", fontSize: 11.5, fontWeight: 600, color: "var(--faint)", textTransform: "uppercase", letterSpacing: 0.5 } }, T.ensAvatar || "avatar"), upAvatar ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("img", { src: upAvatar, alt: "", width: 34, height: 34, style: { width: 34, height: 34, borderRadius: 9, objectFit: "cover", flexShrink: 0, background: "var(--panel-2)" } }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--ui)", fontSize: 12.5, color: "var(--text)" } }, T.ensCustom || "custom image"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !canWrite, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : punkId != null ? /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: punkId, size: 34, radius: 9, fallbackSeed: me.userId }), /* @__PURE__ */ React.createElement("span", { style: { flex: 1, minWidth: 0, fontFamily: "var(--mono)", fontSize: 12.5, color: "var(--text)" } }, `CryptoPunk #${punkId}`), /* @__PURE__ */ React.createElement(Btn, { size: "sm", tone: "danger", disabled: busy || !canWrite, onClick: () => post("/api/ens-avatar", { nftid: null }) }, T.ensClear || "clear")) : /* @__PURE__ */ React.createElement("span", { style: { flex: 1, fontFamily: "var(--ui)", fontSize: 12, color: "var(--faint)" } }, T.ensNotSet || "not set"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canWrite, onClick: () => fileRef.current && fileRef.current.click() }, T.ensUpload || "upload"), /* @__PURE__ */ React.createElement(Btn, { size: "sm", disabled: busy || !canWrite, onClick: () => setPicking(true) }, T.ensChoose || "choose"), /* @__PURE__ */ React.createElement("input", { ref: fileRef, type: "file", accept: "image/*", onChange: onUploadFile, style: { display: "none" } })), walletRow(T.ensEth || "ethereum", "eth", boundEth, bindEth, false), walletRow(T.ensSol || "solana", "sol", boundSol, bindSol, !msg && !st.writeBlocked), st.writeBlocked && hostWalletBind && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", borderBottom: msg ? "1px solid var(--line)" : "none", fontFamily: "var(--ui)", fontSize: 12, lineHeight: 1.5, color: "var(--faint)" } }, st.writeBlocked), msg && /* @__PURE__ */ React.createElement("div", { style: { padding: "10px 16px", fontFamily: "var(--ui)", fontSize: 12, color: msg.tone === "ok" ? "var(--online)" : "var(--danger)" } }, msg.text)), picking && /* @__PURE__ */ React.createElement(DkPunkPicker, { T, onPick: pickPunk, onClose: () => setPicking(false) })), !st.loading && st.registered && /* @__PURE__ */ React.createElement(Card, { label: T.linksCard || "Links" }, SOCIALS.map((s, i) => socialRow(s, i === SOCIALS.length - 1))));
4038
4127
  }
4039
4128
  function DkUpdateCheck({ T }) {
4040
4129
  const [st, setSt] = React.useState("idle");
@@ -4387,6 +4476,8 @@ ${peer.address}`
4387
4476
  }
4388
4477
  function DkAvatar({ peer, size = 30, radius = 7, dot = true }) {
4389
4478
  const d = Math.max(8, size * 0.3);
4479
+ if (peer && peer.app)
4480
+ dot = false;
4390
4481
  return /* @__PURE__ */ React.createElement("div", { style: { position: "relative", width: size, height: size, flexShrink: 0 } }, peer.agent ? /* @__PURE__ */ React.createElement(DkBot, { seed: peer.id, size, radius }) : peer.avatarUrl ? /* @__PURE__ */ React.createElement(DkImgAvatar, { url: peer.avatarUrl, seed: peer.userId || peer.id, size, radius }) : peer.punkId != null ? /* @__PURE__ */ React.createElement(DkPunkAvatar, { id: peer.punkId, size, radius, fallbackSeed: peer.userId || peer.id }) : /* @__PURE__ */ React.createElement(DkIdenticon, { seed: peer.userId || peer.id, size, radius }), dot && /* @__PURE__ */ React.createElement("span", { style: {
4391
4482
  position: "absolute",
4392
4483
  right: -2,
@@ -6132,6 +6223,8 @@ ${peer.address}`
6132
6223
  return;
6133
6224
  const byId = /* @__PURE__ */ new Map();
6134
6225
  for (const p of peers || []) {
6226
+ if (p && p.app)
6227
+ continue;
6135
6228
  const dir = p.lastDir || (String(p.lastMsg || "").startsWith("you: ") ? "out" : "in");
6136
6229
  const quiet = typeof isProtocol === "function" && lastIsProtocol(p, isProtocol);
6137
6230
  byId.set(p.id, { ts: p.lastTs || 0, dir, quiet });
@@ -6408,6 +6501,10 @@ ${peer.address}`
6408
6501
  ensUnbind: "unbind",
6409
6502
  ensNotBound: "not bound",
6410
6503
  ensWebOnly: "Registering a beagles.eth name needs the Beagle phone or desktop app. Your avatar can be set above without one.",
6504
+ ensProven: "signed by this wallet",
6505
+ ensUnproven: "no wallet signature yet",
6506
+ ensProve: "sign",
6507
+ ensSigning: "approve the signature in your wallet\u2026",
6411
6508
  ensAvatar: "avatar",
6412
6509
  ensChoose: "choose",
6413
6510
  ensClear: "clear",
@@ -6603,6 +6700,10 @@ ${peer.address}`
6603
6700
  ensUnbind: "\u89E3\u7ED1",
6604
6701
  ensNotBound: "\u672A\u7ED1\u5B9A",
6605
6702
  ensWebOnly: "\u6CE8\u518C beagles.eth \u540D\u5B57\u9700\u8981 Beagle \u624B\u673A\u6216\u684C\u9762\u5E94\u7528\u3002\u5934\u50CF\u4E0D\u9700\u8981\u540D\u5B57\uFF0C\u53EF\u5728\u4E0A\u65B9\u76F4\u63A5\u8BBE\u7F6E\u3002",
6703
+ ensProven: "\u5DF2\u7531\u8BE5\u94B1\u5305\u7B7E\u540D",
6704
+ ensUnproven: "\u5C1A\u65E0\u94B1\u5305\u7B7E\u540D",
6705
+ ensProve: "\u7B7E\u540D",
6706
+ ensSigning: "\u8BF7\u5728\u94B1\u5305\u4E2D\u786E\u8BA4\u7B7E\u540D\u2026",
6606
6707
  ensAvatar: "\u5934\u50CF",
6607
6708
  ensChoose: "\u9009\u62E9",
6608
6709
  ensClear: "\u6E05\u9664",
@@ -6937,10 +7038,10 @@ ${peer.address}`
6937
7038
  window.location.reload();
6938
7039
  }, [me && me.beagleVer]);
6939
7040
  const [assistantTs, setAssistantTs] = React.useState(loadAssistantTs);
6940
- const peers = React.useMemo(() => (data.peers || []).concat([Object.assign({}, ASSISTANT_PEER, {
7041
+ const peers = React.useMemo(() => (data.peers || []).concat(hostAssistant ? [Object.assign({}, ASSISTANT_PEER, {
6941
7042
  lastTs: assistantTs,
6942
7043
  lastTime: assistantTs ? dkClock(assistantTs) : ""
6943
- })]), [data.peers, assistantTs]);
7044
+ })] : []), [data.peers, assistantTs]);
6944
7045
  const [pendingAddr, setPendingAddr] = React.useState(() => dkParseDeepLinkAddress());
6945
7046
  React.useEffect(() => {
6946
7047
  const onHash = () => {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Cached fetches for the discovery directories (the leaderboard and the
3
+ * beagles.eth /names listing).
4
+ *
5
+ * This used to cache successes only. When the gateway was down every UI poll —
6
+ * one every 2.5 s, per open tab, per machine — started a fresh request, so the
7
+ * upstream got hammered exactly when it was least able to answer. On
8
+ * 2026-09-28 the beagle servers on one home network were about 82% of the
9
+ * gateway's traffic and the day's total reached 139k requests against a 100k
10
+ * free limit. A failing upstream must cost LESS traffic than a working one,
11
+ * not more.
12
+ *
13
+ * Three rules, and they are the whole module:
14
+ * • a success is cached for `ttlMs` (5 minutes),
15
+ * • a failure is remembered for `failTtlMs` (60 s) and nothing is refetched
16
+ * inside that window — a stale body is served if we have one, because a
17
+ * five-minute-old directory listing is worth more than an error,
18
+ * • one in-flight request per URL, shared by every caller, so the poll that
19
+ * arrives while a fetch is running joins it instead of starting another.
20
+ */
21
+ export interface DiscoverFetchOptions {
22
+ /** How long a good answer stays good. */
23
+ ttlMs?: number;
24
+ /** How long a failure suppresses refetching. */
25
+ failTtlMs?: number;
26
+ /** Abort a request that takes longer than this. */
27
+ timeoutMs?: number;
28
+ /** Injected for tests. */
29
+ fetchImpl?: typeof fetch;
30
+ now?: () => number;
31
+ }
32
+ export declare const DISCOVER_TTL_MS: number;
33
+ export declare const DISCOVER_FAIL_TTL_MS = 60000;
34
+ export interface DiscoverFetch {
35
+ (url: string): Promise<unknown>;
36
+ /** Forget everything about this URL: the next call refetches. Used when we
37
+ * KNOW the upstream changed (we just wrote a record), so the Names tab
38
+ * reflects it now instead of in five minutes. A request already in flight
39
+ * when this is called carries pre-change data, so its result is discarded
40
+ * rather than cached. */
41
+ invalidate(url: string): void;
42
+ }
43
+ export declare function createDiscoverFetch(opts?: DiscoverFetchOptions): DiscoverFetch;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Cached fetches for the discovery directories (the leaderboard and the
3
+ * beagles.eth /names listing).
4
+ *
5
+ * This used to cache successes only. When the gateway was down every UI poll —
6
+ * one every 2.5 s, per open tab, per machine — started a fresh request, so the
7
+ * upstream got hammered exactly when it was least able to answer. On
8
+ * 2026-09-28 the beagle servers on one home network were about 82% of the
9
+ * gateway's traffic and the day's total reached 139k requests against a 100k
10
+ * free limit. A failing upstream must cost LESS traffic than a working one,
11
+ * not more.
12
+ *
13
+ * Three rules, and they are the whole module:
14
+ * • a success is cached for `ttlMs` (5 minutes),
15
+ * • a failure is remembered for `failTtlMs` (60 s) and nothing is refetched
16
+ * inside that window — a stale body is served if we have one, because a
17
+ * five-minute-old directory listing is worth more than an error,
18
+ * • one in-flight request per URL, shared by every caller, so the poll that
19
+ * arrives while a fetch is running joins it instead of starting another.
20
+ */
21
+ export const DISCOVER_TTL_MS = 5 * 60_000;
22
+ export const DISCOVER_FAIL_TTL_MS = 60_000;
23
+ export function createDiscoverFetch(opts = {}) {
24
+ const ttlMs = opts.ttlMs ?? DISCOVER_TTL_MS;
25
+ const failTtlMs = opts.failTtlMs ?? DISCOVER_FAIL_TTL_MS;
26
+ const timeoutMs = opts.timeoutMs ?? 10_000;
27
+ const doFetch = opts.fetchImpl ?? fetch;
28
+ const now = opts.now ?? Date.now;
29
+ const cache = new Map();
30
+ const inFlight = new Map();
31
+ const generation = new Map();
32
+ const fetchDiscoverJson = async function fetchDiscoverJson(url) {
33
+ const hit = cache.get(url);
34
+ if (hit && hit.at !== undefined && now() - hit.at < ttlMs)
35
+ return hit.body;
36
+ // Someone is already asking. Join them rather than making it worse — under
37
+ // a slow upstream this is the difference between one request and one per
38
+ // poll per tab.
39
+ const running = inFlight.get(url);
40
+ if (running)
41
+ return running;
42
+ if (hit && hit.failedAt !== undefined && now() - hit.failedAt < failTtlMs) {
43
+ // Inside the failure window nothing is fetched. Stale beats nothing;
44
+ // with nothing to serve, the caller gets the error it would have got.
45
+ if (hit.at !== undefined)
46
+ return hit.body;
47
+ throw hit.error ?? new Error("upstream unavailable");
48
+ }
49
+ const gen = generation.get(url) ?? 0;
50
+ const attempt = (async () => {
51
+ const ctl = new AbortController();
52
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
53
+ try {
54
+ const r = await doFetch(url, { signal: ctl.signal });
55
+ if (!r.ok)
56
+ throw new Error(`upstream ${r.status}`);
57
+ const body = await r.json();
58
+ if ((generation.get(url) ?? 0) === gen)
59
+ cache.set(url, { body, at: now() });
60
+ return body;
61
+ }
62
+ catch (e) {
63
+ const err = e instanceof Error ? e : new Error(String(e));
64
+ const prev = cache.get(url);
65
+ // Keep whatever good body we had: it is what the next 60 s will serve.
66
+ if ((generation.get(url) ?? 0) === gen) {
67
+ cache.set(url, { body: prev?.body, at: prev?.at, failedAt: now(), error: err });
68
+ }
69
+ if (prev && prev.at !== undefined)
70
+ return prev.body;
71
+ throw err;
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ // After an invalidate the entry is already gone and a newer request
76
+ // may own the slot; the generation says which of us this is.
77
+ if ((generation.get(url) ?? 0) === gen)
78
+ inFlight.delete(url);
79
+ }
80
+ })();
81
+ inFlight.set(url, attempt);
82
+ return attempt;
83
+ };
84
+ fetchDiscoverJson.invalidate = (url) => {
85
+ cache.delete(url);
86
+ inFlight.delete(url);
87
+ generation.set(url, (generation.get(url) ?? 0) + 1);
88
+ };
89
+ return fetchDiscoverJson;
90
+ }
package/dist/server.js CHANGED
@@ -25,6 +25,8 @@ import * as peerAddr from "@decentnetwork/peer";
25
25
  import { bridgeRoster } from "./bridge-roster.js";
26
26
  import { isSupervised } from "./relaunch.js";
27
27
  import { historyStats } from "./assistant-stats.js";
28
+ import { createDiscoverFetch } from "./discover-cache.js";
29
+ import { applyWalletBind } from "@decentnetwork/beagle-ui/wallet-proof";
28
30
  // Directory holding the built desktop UI bundle (index.html, app.js, vendor/).
29
31
  // scripts/build-ui.mjs emits it next to this compiled module at dist/ui/desktop/.
30
32
  const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
@@ -33,28 +35,10 @@ const DESKTOP_DIR = join(dirname(fileURLToPath(import.meta.url)), "desktop");
33
35
  // (which probes all exits on each poll) stays responsive over a lossy mesh.
34
36
  const EXIT_PROBE_PORT = 8888;
35
37
  const EXIT_PROBE_TIMEOUT_MS = 2000;
36
- /** Server-side fetch for the discovery directories (leaderboard / ENS names),
37
- * with a small TTL cache so tab switches don't hammer the upstreams. */
38
- const DISCOVER_CACHE_TTL_MS = 5 * 60_000;
39
- const discoverCache = new Map();
40
- async function fetchDiscoverJson(url) {
41
- const hit = discoverCache.get(url);
42
- if (hit && Date.now() - hit.at < DISCOVER_CACHE_TTL_MS)
43
- return hit.body;
44
- const ctl = new AbortController();
45
- const timer = setTimeout(() => ctl.abort(), 10_000);
46
- try {
47
- const r = await fetch(url, { signal: ctl.signal });
48
- if (!r.ok)
49
- throw new Error(`upstream ${r.status}`);
50
- const body = await r.json();
51
- discoverCache.set(url, { at: Date.now(), body });
52
- return body;
53
- }
54
- finally {
55
- clearTimeout(timer);
56
- }
57
- }
38
+ /** Server-side fetch for the discovery directories (leaderboard / ENS names).
39
+ * Successes are cached, FAILURES ARE TOO, and concurrent callers share one
40
+ * request — see discover-cache.ts for the outage this comes from. */
41
+ const fetchDiscoverJson = createDiscoverFetch();
58
42
  // ── beagles.eth names (ens-gateway worker) ──────────────────────────────────
59
43
  // The same registry the mobile apps register into. Web-side registration
60
44
  // signs the record with THIS node's Carrier identity (XEdDSA via the backend
@@ -190,7 +174,7 @@ async function ensSignAndSet(call, record) {
190
174
  }
191
175
  // The record changed — drop the cached /names listing so the Names tab
192
176
  // reflects it immediately instead of after the 5-min TTL.
193
- discoverCache.delete(`${ENS_GATEWAY}/names`);
177
+ fetchDiscoverJson.invalidate(`${ENS_GATEWAY}/names`);
194
178
  return { ok: true };
195
179
  }
196
180
  catch (e) {
@@ -1371,13 +1355,11 @@ export function startBeagleServer(opts) {
1371
1355
  }
1372
1356
  if (req.method === "POST" && url === "/api/ens-bind-wallet") {
1373
1357
  const body = await readBody(req);
1374
- const coinType = body.chain === "eth" ? "60" : body.chain === "sol" ? "501" : null;
1375
- const addr = typeof body.address === "string" ? body.address.trim() : "";
1376
- const remove = addr === "";
1377
- const okAddr = remove
1378
- || (coinType === "60" ? /^0x[0-9a-fA-F]{40}$/.test(addr) : /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(addr));
1379
- if (!coinType || !okAddr) {
1380
- sendJson(res, 400, { ok: false, error: "invalid chain or address" });
1358
+ // Format check up front, before any identity or gateway round trip;
1359
+ // applyWalletBind repeats it with the record in hand.
1360
+ const pre = applyWalletBind(null, { chain: String(body.chain ?? ""), address: body.address, userid: "" });
1361
+ if (!pre.ok) {
1362
+ sendJson(res, pre.status, { ok: false, error: pre.error });
1381
1363
  return;
1382
1364
  }
1383
1365
  const diag = await opts.call({ op: "diag" });
@@ -1395,16 +1377,22 @@ export function startBeagleServer(opts) {
1395
1377
  sendJson(res, 403, { ok: false, error: "this name was registered by your mobile wallet — bind addresses there" });
1396
1378
  return;
1397
1379
  }
1398
- const addresses = { ...(mine.addresses ?? {}) };
1399
- if (remove)
1400
- delete addresses[coinType];
1401
- else
1402
- addresses[coinType] = addr;
1380
+ // The wallet's signed proof (wallet-proof.js) is checked here and
1381
+ // stored as texts["proof.eth"|"proof.sol"] beside the address.
1382
+ const bound = applyWalletBind(mine, {
1383
+ chain: String(body.chain), address: body.address, userid: identity.userid,
1384
+ proof: (body.proof ?? null),
1385
+ });
1386
+ if (!bound.ok) {
1387
+ sendJson(res, bound.status, { ok: false, error: bound.error });
1388
+ return;
1389
+ }
1390
+ const { addresses, texts } = bound;
1403
1391
  const r = await ensSignAndSet(opts.call, {
1404
1392
  name: mine.name,
1405
1393
  owner: identity.userid,
1406
1394
  addresses,
1407
- texts: mine.texts ?? {},
1395
+ texts,
1408
1396
  referee: mine.referee ?? "",
1409
1397
  nft: mine.nft ?? "",
1410
1398
  nftid: mine.nftid ?? 0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decentnetwork/beagle",
3
- "version": "0.1.117",
3
+ "version": "0.1.119",
4
4
  "description": "Beagle — P2P chat, file transfer and calls for regular users, on the Decent Network. No admin privilege required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -26,6 +26,7 @@
26
26
  "start": "node dist/cli.js"
27
27
  },
28
28
  "dependencies": {
29
+ "@decentnetwork/beagle-ui": "^0.2.33",
29
30
  "@decentnetwork/chat-components": "^0.1.3",
30
31
  "@decentnetwork/lan": "^0.1.283",
31
32
  "@decentnetwork/peer": "^0.1.168",
@@ -35,7 +36,6 @@
35
36
  "yargs": "^17.7.2"
36
37
  },
37
38
  "devDependencies": {
38
- "@decentnetwork/beagle-ui": "^0.2.31",
39
39
  "@types/js-yaml": "^4.0.9",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/yargs": "^17.0.32",