@ghostlytools/cli 1.0.0 → 1.0.1

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.
Files changed (28) hide show
  1. package/README.md +66 -21
  2. package/SKILL.md +99 -18
  3. package/dist/assets/{endpoint-h3cqRyRz.js → endpoint-CaSbSFDw.js} +2 -2
  4. package/dist/assets/{endpoint-h3cqRyRz.js.map → endpoint-CaSbSFDw.js.map} +1 -1
  5. package/dist/assets/{fileBytes-ByS16quP.js → fileBytes-CLv0gryb.js} +2 -2
  6. package/dist/assets/{fileBytes-ByS16quP.js.map → fileBytes-CLv0gryb.js.map} +1 -1
  7. package/dist/assets/{fileBytesIdb-COzoilo9.js → fileBytesIdb-CKCtzMTy.js} +2 -2
  8. package/dist/assets/{fileBytesIdb-COzoilo9.js.map → fileBytesIdb-CKCtzMTy.js.map} +1 -1
  9. package/dist/assets/{fileBytesOpfs-B27wAMUh.js → fileBytesOpfs-By55XVTI.js} +3 -3
  10. package/dist/assets/{fileBytesOpfs-B27wAMUh.js.map → fileBytesOpfs-By55XVTI.js.map} +1 -1
  11. package/dist/assets/{hyperdhtRelay-BKsFpPjr.js → hyperdhtRelay-_rJsllcP.js} +4 -4
  12. package/dist/assets/{hyperdhtRelay-BKsFpPjr.js.map → hyperdhtRelay-_rJsllcP.js.map} +1 -1
  13. package/dist/assets/{mainline-DOncVbJN.js → mainline-B0mUxYPH.js} +12 -5
  14. package/dist/assets/mainline-B0mUxYPH.js.map +1 -0
  15. package/dist/assets/{server-5T5l8hfy.js → server-C-8wbsAX.js} +1833 -319
  16. package/dist/assets/server-C-8wbsAX.js.map +1 -0
  17. package/dist/ghostly.mjs +24088 -564
  18. package/dist/ghostly.mjs.map +1 -1
  19. package/examples/call-echo.mjs +116 -0
  20. package/examples/claude-code-agent.sh +55 -0
  21. package/examples/echo-bot.mjs +21 -0
  22. package/examples/echo-bot.sh +15 -0
  23. package/examples/payment-bot.mjs +59 -0
  24. package/package.json +2 -1
  25. package/dist/assets/mainline-DOncVbJN.js.map +0 -1
  26. package/dist/assets/server-5T5l8hfy.js.map +0 -1
  27. package/dist/assets/src-DptDr2et.js +0 -20971
  28. package/dist/assets/src-DptDr2et.js.map +0 -1
@@ -1,5 +1,4 @@
1
- import { $n as readBeaconHead, $r as safeBlobType, $t as CapsExchange, Aa as fromBase64Url, Ai as readReply, An as findEndpoint, Ar as HoldRefusedError, At as didDhtFromPublicKey, Bi as GhostlyHttpError, Bn as COMMUNITY_TOPOLOGY, Br as identityRevocationValue, Bt as TEST_USDT_FAUCET_AMOUNT, C as groupWakeFrame, Ca as createIdentity, Ci as queueReaction, Cr as wakeRequest, D as GROUP_READ_NOTE, Di as validReactionNumber, Dn as ENDPOINT, Dr as HOLD_CAPABILITY, Ea as publicKeyFromZ32, Ei as readReaction, Er as EDIT_CAPABILITY, F as mentionsMember, Fa as utf8Encode, Fi as rosterHas, Fn as pairedMessageFrame, Fr as IDENTITY_REVOCATION_LABEL, Ft as EVM_TEST_CHAINS, Gi as decodeControl, Gn as lobbyKeys, Gr as lastSharedWithMe, Gt as formatPaymentAmount, Hi as parseLocalTarget, Hn as beaconRecords, Hr as newIdentityBinding, Ht as WALLET_NETWORKS, I as groupName, Ii as verifyCommitSignature, In as MESH_HUBS, It as ONCHAIN_PROVIDER, Ji as RTC_CONFIG, Jn as mergeLobby, Jr as validExternalKey, Jt as SPARK_NETWORKS, Ki as encodeControl, Kn as lobbyRecords, Kr as PeerProofs, Kt as validatePaymentTarget, Li as edgeParams, Ln as mayBeHub, Lr as IdentityExchange, Lt as PaymentPreflightError, M as GROUP_EDIT_TEXT_BYTES, Ma as toBase64Url, Mi as GROUP_MEMBER_CAP, Mn as parseCashuRequestPayload, Mr as readManifest, N as carryMentions, Na as toZ32, Nn as parseFedimintRequestPayload, Nt as signDidDhtPacket, O as GroupSession, Oa as sanitizeAvatar, Oi as wireReaction, On as cashuRequestPayload, Or as HOLD_LIMITS, P as parseCommunityEdit, Pa as utf8Decode, Pi as MEMBER_KEY, Pn as GhostLink, Pt as ETHEREUM_USDT, Qn as readBeacon, Qt as isBitcoinAddress, Ri as RELAY_POLL_INTERVALS, Rn as meshHubs, Rt as SEPOLIA_TEST_USDT, S as GroupWakeLimiter, Si as nextReactionNumber, Sr as relayRequest, T as parseGroupWakeFrame, Ta as identityFromSeedB64, Ti as reactionIsNewer, Tr as vapidKeysMatch, Ui as webLocalFetch, Un as doorHubs, Ur as receivedIdentityStatus, Ut as assertTokenUnits, Vi as formatLocalTarget, Vn as beaconKeys, Vr as identityStatement, Vt as USDT_PUBLIC_RPC, Wi as LIMITS, Wn as freshHubs, Wr as revokesIdentity, Wt as assertWholeSats, Xn as pickHubs, Xr as automaticTransport, Xt as sparkAddressKind, Yn as newerHead, Yr as TRANSPORTS, Yt as SPARK_PROVIDER, Zn as rankHubs, Zr as parseImageMeta, Zt as sparkInvoiceDetails, _ as knockIdentity, _a as PacketTooLargeError, _i as readForwarded, _r as readPin, a as sealGroupTyping, aa as createChatInvite, ai as fileMessageText, ar as transferStalled, b as readKnocks, bi as REACTION_LIMITS, br as WakeLimiter, c as GROUP_READ_NOTE_COMMUNITY, ci as parseVideoMeta, cr as TypingSender, d as decodeCommunityLink, da as RelayTransport, di as EDIT_BUFFER_MS, ea as emptyLinkRecords, en as DHT_TEXT_CAPABILITY, er as readLobby, f as encodeCommunityLink, fa as currentRelays, fi as EDIT_RATE_WINDOW_MS, fr as PIN_LIMITS, g as entryParams, ga as withRequestOptions, gi as forwardedAgain, gr as pinNumberHolds, h as encodeGroupEntryLink, ha as isDiscoveryBudgetError, hi as parseLinkPreview, hr as pinIsNewer, i as openGroupTyping, ia as OwnInviteError, in as isPubkyKey, ir as transferEnded, ja as randomBytes, ji as replySnippet, jn as isFederationId, jr as newMailbox, jt as encodeDidDhtPacket, k as groupMessageId, ka as bytesEqual, ki as pairedReplyAuthor, kn as fedimintRequestPayload, kr as HoldKeys, kt as didDhtDocument, l as communityHasFrame, la as inviteOwnership, li as DHT_MESSAGE_TTL, m as decodeGroupEntryLink, ma as budgetRetryMs, mi as dhtEditId, mr as nextPinNumber, n as GroupTypingBoard, na as LEGACY_SERVICES, nn as dialDescriptors, nr as ChatFiles, nt as isAtprotoDid, o as COMMUNITY_LIMITS, oa as decodeInviteCode, oi as parseVoiceMeta, pa as normalizeRelayUrl, pi as RateWindow, pr as mayPin, qn as mergeBeacon, qr as emptyProofLedger, qt as walletNetworkOf, r as groupTypingEpoch, ra as serviceIdFromName, rr as FILE_LIMITS, rt as normalizeAtprotoHandle, s as CommunitySession, sa as encodeInviteCode, ta as HTTP_SERVICE_PROTO, tn as capsDescriptors, tr as shouldBeHub, u as communityMessageId, ua as DEFAULT_RELAYS, ui as peekDhtMailbox, ur as typingActivity, v as knockRecords, vi as COMMUNITY_REACTION_FRAME, vr as receivedTimestamp, w as groupWakes, wa as identityFromSeed, wi as reactionEmoji, wr as checkPushEndpoint, xi as ReactionWindow, xr as newWakeToken, y as mergeKnocks, yi as GROUP_REACTION_FRAME, yr as WAKE_CALL_INTERVAL_MS, zn as pickMeshHubs, zr as emptyIdentityLedger, zt as SEPOLIA_TEST_USDT_FAUCET } from "./src-DptDr2et.js";
2
- import { $ as chosenResolver, A as isTestMint, At as __exportAll, B as reserveAdapterIds, C as cashuMint, Ct as deleteDatabase, D as DEFAULT_MINTS, Dt as transact, E as BITCOIN_INVOICE_ON_TESTNET, Et as store, F as MAX_BACKUP_BYTES, G as isNothingSpentError, H as PROVIDER_PLATFORMS, I as identityProviders, J as assertPublicServer, K as networkMode, L as onAdaptersChanged, M as mintNetwork, N as paysItsOwnInvoices, O as TEST_MINT, Ot as wrap, P as BACKUP_MEDIA_TYPE, Q as verifyIdentity, R as registeredLightningProviders, S as CASHU_MINT_SOURCE, St as databaseName, T as WALLET_TYPES, Tt as openDb, U as SourceConfigError, V as NothingSpentError, W as SourceUnreachableError, X as setOwnDidSource, Y as resolveAtprotoDid, Z as boundedIdentityFetch, _ as unsealSeed, a as createFailure, at as paymentAlias, b as WrongNetworkError, c as paymentNetwork, ct as readStored, d as CASHU_CARD, dt as streamStored, et as groupReply, f as LightningCards, g as sealSeed, gt as fileBytesOf, h as newDeviceKey, ht as fileBytes, i as assertConfirmedReal, it as replyTo, j as isWorthlessMint, k as fakesLightning, kt as decodeBolt11, l as paymentNetworksOf, lt as removeStored, m as intentRepository, n as loadFedimintSdk, nt as receivedPairedReply, o as createTiming, ot as paymentWireId, p as ProviderSources, pt as blobDigest, q as redact, r as WALLET_NAMES, rt as replyRef, s as crossNetwork, st as FileAppender, t as fedimintDatabase, tt as pairedWireReply, u as walletInstances, ut as storedSize, v as ModeChanged, vt as removeFileBytes, wt as fileStore, x as networkLabel, xt as STORES, y as ModeGate, z as registeredOnchainProviders } from "../ghostly.mjs";
1
+ import { $ as boundedIdentityFetch, $n as LIVENESS_PING_MS, $r as IDENTITY_REVOCATION_LABEL, $t as GroupWakeLimiter, A as TEST_MINT, Ai as ReactionWindow, An as assertTokenUnits, Ar as nextPinNumber, At as transact, B as registeredLightningProviders, Ba as toZ32, Bi as replySnippet, Bn as isPubkyKey, Br as vapidKeysMatch, Bt as communityHasFrame, C as networkLabel, Ca as normalizeRelayUrl, Ci as dhtEditId, Cn as ONCHAIN_PROVIDER, Cr as transferEnded, Ct as STORES, D as WALLET_TYPES, Da as PacketTooLargeError, Di as COMMUNITY_REACTION_FRAME, Dn as TEST_USDT_FAUCET_AMOUNT, Dt as fileStore, Ea as withRequestOptions, Ei as readForwarded, En as SEPOLIA_TEST_USDT_FAUCET, Er as typingActivity, Et as deleteDatabase, F as paysItsOwnInvoices, Fa as sanitizeAvatar, Fi as readReaction, Fn as SPARK_NETWORKS, Fr as WakeLimiter, Ft as openGroupTyping, G as SourceConfigError, Gi as statusCardText, Gn as cashuRequestPayload, Gr as HoldKeys, Gt as decodeGroupEntryLink, H as reserveAdapterIds, Ha as utf8Encode, Hi as STATUS_CARD_LIMITS, Hn as engineText, Hr as EDIT_CAPABILITY, Ht as decodeCommunityLink, I as BACKUP_MEDIA_TYPE, Ia as bytesEqual, Ii as validReactionNumber, In as SPARK_PROVIDER, Ir as newWakeToken, It as sealGroupTyping, J as networkMode, Jn as isFederationId, Jr as readManifest, Jt as knockIdentity, K as SourceUnreachableError, Ki as receivedTimestamp, Kn as fedimintRequestPayload, Kr as HoldRefusedError, Kt as encodeGroupEntryLink, L as MAX_BACKUP_BYTES, La as fromBase64Url, Li as wireReaction, Ln as sparkAddressKind, Lr as relayRequest, Lt as COMMUNITY_LIMITS, M as isTestMint, Ma as identityFromSeed, Mi as queueReaction, Mn as formatPaymentAmount, Mr as pinNumberHolds, N as isWorthlessMint, Na as identityFromSeedB64, Ni as reactionEmoji, Nn as validatePaymentTarget, Nr as readPin, Nt as GroupTypingBoard, O as BITCOIN_INVOICE_ON_TESTNET, Oi as GROUP_REACTION_FRAME, On as USDT_PUBLIC_RPC, Or as PIN_LIMITS, Ot as openDb, P as mintNetwork, Pa as publicKeyFromZ32, Pi as reactionIsNewer, Pn as walletNetworkOf, Pr as WAKE_CALL_INTERVAL_MS, Pt as groupTypingEpoch, Q as setOwnDidSource, Qi as edgeParams, Qn as INVITE_TAKEN, Qr as dialDescriptors, R as identityProviders, Ra as randomBytes, Ri as pairedReplyAuthor, Rn as sparkInvoiceDetails, Rr as wakeRequest, Rt as CommunitySession, S as WrongNetworkError, Sa as currentRelays, Si as RateWindow, Sn as EVM_TEST_CHAINS, Sr as FILE_LIMITS, T as cashuMint, Ta as isDiscoveryBudgetError, Ti as forwardedAgain, Tn as SEPOLIA_TEST_USDT, Tr as TypingSender, Tt as databaseName, U as NothingSpentError, Ua as __exportAll, Ui as checkStatusCard, Un as decodeBolt11, Ur as HOLD_CAPABILITY, Ut as encodeCommunityLink, V as registeredOnchainProviders, Va as utf8Decode, Vi as BUTTON_ID, Vn as engineError, Vt as communityMessageId, W as PROVIDER_PLATFORMS, Wi as sameButtonText, Wn as ENDPOINT, Wr as HOLD_LIMITS, X as assertPublicServer, Xi as rosterHas, Xn as parseFedimintRequestPayload, Xr as DHT_TEXT_CAPABILITY, Xt as mergeKnocks, Y as redact, Yi as MEMBER_KEY, Yn as parseCashuRequestPayload, Yr as CapsExchange, Yt as knockRecords, Z as resolveAtprotoDid, Zi as verifyCommitSignature, Zn as GhostLink, Zr as capsDescriptors, Zt as readKnocks, _ as newDeviceKey, _a as decodeInviteCode, _i as parseVideoMeta, _n as didDhtFromPublicKey, _r as readBeacon, _t as fileBytes, a as createFailure, aa as webLocalFetch, ai as receivedIdentityStatus, an as GroupSession, ar as COMMUNITY_TOPOLOGY, at as replyRef, b as ModeChanged, ba as DEFAULT_RELAYS, bi as EDIT_BUFFER_MS, bn as signDidDhtPacket, br as shouldBeHub, bt as removeFileBytes, c as paymentNetwork, ca as encodeControl, ci as PeerProofs, cr as doorHubs, ct as paymentWireId, d as networkReason, da as emptyLinkRecords, di as TRANSPORTS, dn as parseCommunityEdit, dr as lobbyRecords, dt as removeStored, ea as RELAY_POLL_INTERVALS, ei as IdentityExchange, en as groupWakeFrame, er as pairedMessageFrame, et as verifyIdentity, f as rpcHost, fa as HTTP_SERVICE_PROTO, fi as automaticTransport, fn as mentionsMember, fr as mergeBeacon, ft as storedSize, g as intentRepository, ga as createChatInvite, gi as parseVoiceMeta, gn as didDhtDocument, gr as rankHubs, h as ProviderSources, ha as OwnInviteError, hi as fileMessageText, hn as normalizeAtprotoHandle, hr as pickHubs, ht as blobDigest, i as assertConfirmedReal, ia as parseLocalTarget, ii as newIdentityBinding, in as GROUP_READ_NOTE, ir as pickMeshHubs, it as receivedPairedReply, j as fakesLightning, ja as createIdentity, ji as nextReactionNumber, jn as assertWholeSats, jr as pinIsNewer, jt as wrap, k as DEFAULT_MINTS, ki as REACTION_LIMITS, kn as WALLET_NETWORKS, kr as mayPin, kt as store, l as paymentNetworksOf, la as RTC_CONFIG, li as emptyProofLedger, ln as GROUP_EDIT_TEXT_BYTES, lr as freshHubs, lt as FileAppender, m as LightningCards, ma as serviceIdFromName, mi as safeBlobType, mn as isAtprotoDid, mr as newerHead, n as loadFedimintSdk, na as GhostlyHttpError, ni as identityRevocationValue, nn as parseGroupWakeFrame, nr as mayBeHub, nt as groupReply, o as createTiming, oa as LIMITS, oi as revokesIdentity, on as groupMessageId, or as beaconKeys, ot as replyTo, p as CASHU_CARD, pa as LEGACY_SERVICES, pi as parseImageMeta, pn as groupName, pr as mergeLobby, pt as streamStored, q as isNothingSpentError, qi as GROUP_MEMBER_CAP, qn as findEndpoint, qr as newMailbox, qt as entryParams, r as WALLET_NAMES, ra as formatLocalTarget, ri as identityStatement, rr as meshHubs, rt as pairedWireReply, s as crossNetwork, sa as decodeControl, si as lastSharedWithMe, sr as beaconRecords, st as paymentAlias, t as fedimintDatabase, ta as traceLink, ti as emptyIdentityLedger, tn as groupWakes, tr as MESH_HUBS, tt as chosenResolver, u as walletInstances, ua as parseCallSignal, ui as validExternalKey, un as carryMentions, ur as lobbyKeys, ut as readStored, v as sealSeed, va as encodeInviteCode, vi as DHT_MESSAGE_TTL, vn as encodeDidDhtDeactivated, vr as readBeaconHead, vt as fileBytesOf, w as CASHU_MINT_SOURCE, wa as budgetRetryMs, wi as parseLinkPreview, wn as PaymentPreflightError, wr as transferStalled, wt as clearProfileStores, x as ModeGate, xa as RelayTransport, xi as EDIT_RATE_WINDOW_MS, xn as ETHEREUM_USDT, xr as ChatFiles, y as unsealSeed, ya as inviteOwnership, yi as peekDhtMailbox, yn as encodeDidDhtPacket, yr as readLobby, z as onAdaptersChanged, za as toBase64Url, zi as readReply, zn as isBitcoinAddress, zr as checkPushEndpoint, zt as GROUP_READ_NOTE_COMMUNITY } from "../ghostly.mjs";
3
2
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
4
3
  import { hkdf } from "@noble/hashes/hkdf.js";
5
4
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -11,7 +10,7 @@ import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
11
10
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
12
11
  import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
13
12
  import { Interface, Transaction, getAddress, keccak256 } from "ethers";
14
- import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
13
+ import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
15
14
  import { tapLeafHash } from "@scure/btc-signer/payment.js";
16
15
  import { Transaction as Transaction$2 } from "@scure/btc-signer";
17
16
  import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
@@ -157,18 +156,24 @@ var UsdtAdapter = class UsdtAdapter {
157
156
  return adapter;
158
157
  }
159
158
  async rpc(method, params = []) {
160
- const response = await fetch(this.config.provider, {
161
- method: "POST",
162
- headers: { "Content-Type": "application/json" },
163
- body: JSON.stringify({
164
- jsonrpc: "2.0",
165
- id: ++this.nextId,
166
- method,
167
- params
168
- }),
169
- signal: AbortSignal.timeout(15e3)
170
- });
171
- if (!response.ok) throw new Error("USDT RPC unavailable");
159
+ let response;
160
+ try {
161
+ response = await fetch(this.config.provider, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: JSON.stringify({
165
+ jsonrpc: "2.0",
166
+ id: ++this.nextId,
167
+ method,
168
+ params
169
+ }),
170
+ signal: AbortSignal.timeout(15e3)
171
+ });
172
+ } catch (error) {
173
+ const reason = networkReason(error, rpcHost(this.config.provider));
174
+ throw reason ? Object.assign(new Error(reason), { cause: error }) : error;
175
+ }
176
+ if (!response.ok) throw engineError("usdtRpcUnavailable");
172
177
  const result = await response.json();
173
178
  if (result.error || !Object.prototype.hasOwnProperty.call(result, "result")) throw new Error("USDT RPC rejected the operation");
174
179
  return result.result;
@@ -206,7 +211,7 @@ var UsdtAdapter = class UsdtAdapter {
206
211
  if (!block.baseFeePerGas) throw new Error("This wallet requires an EIP-1559 chain");
207
212
  const priority = BigInt(await this.rpc("eth_maxPriorityFeePerGas"));
208
213
  const maxFeePerGas = BigInt(block.baseFeePerGas) * 2n + priority, gasLimit = (estimate * 120n + 99n) / 100n;
209
- if (BigInt((await this.balances()).gasBalance) < gasLimit * maxFeePerGas) throw new Error("This needs a little Sepolia ETH for gas first");
214
+ if (BigInt((await this.balances()).gasBalance) < gasLimit * maxFeePerGas) throw engineError("usdtNeedsSepoliaGas");
210
215
  const raw = await account.signTransaction({
211
216
  type: 2,
212
217
  to: SEPOLIA_TEST_USDT_FAUCET,
@@ -269,7 +274,7 @@ var UsdtAdapter = class UsdtAdapter {
269
274
  const from = await this.address(), to = this.config.token;
270
275
  if (same(target.address, from) || same(target.address, to)) throw new Error("Choose a different recipient address");
271
276
  const { balance, gasBalance } = await this.balances();
272
- if (BigInt(balance) < BigInt(amount)) throw new Error("Insufficient token balance");
277
+ if (BigInt(balance) < BigInt(amount)) throw engineError("usdtNotEnoughTokens");
273
278
  const data = erc20.encodeFunctionData("transfer", [getAddress(target.address), BigInt(amount)]);
274
279
  const nonce = Number(BigInt(await this.rpc("eth_getTransactionCount", [from, "pending"])));
275
280
  if (!Number.isSafeInteger(nonce)) throw new Error("Invalid account nonce");
@@ -285,8 +290,8 @@ var UsdtAdapter = class UsdtAdapter {
285
290
  const maxFee = BigInt(block.baseFeePerGas) * 2n + priority;
286
291
  const gasLimit = (estimate * 120n + 99n) / 100n;
287
292
  const maximum = gasLimit * maxFee;
288
- if (maximum > BigInt(feeCap) || maximum > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error("Estimated maximum gas exceeds your limit");
289
- if (BigInt(gasBalance) < maximum) throw new Error("Insufficient ETH for gas");
293
+ if (maximum > BigInt(feeCap) || maximum > BigInt(Number.MAX_SAFE_INTEGER)) throw engineError("usdtGasAboveLimit");
294
+ if (BigInt(gasBalance) < maximum) throw engineError("usdtNotEnoughGas");
290
295
  const prepared = {
291
296
  from,
292
297
  to,
@@ -575,20 +580,21 @@ var UsdtWallet = class {
575
580
  throw error;
576
581
  }
577
582
  this.saved = saved;
583
+ this.view = {
584
+ configured: true,
585
+ locked: true,
586
+ automatic: !!deviceKey,
587
+ ...config,
588
+ balance: "0",
589
+ gasBalance: "0"
590
+ };
578
591
  if (epoch !== this.epoch) {
579
592
  await adapter.dispose();
580
- this.view = {
581
- configured: true,
582
- locked: true,
583
- automatic: !!deviceKey,
584
- ...config,
585
- balance: "0",
586
- gasBalance: "0"
587
- };
588
593
  this.changed();
589
594
  return;
590
595
  }
591
596
  this.adapter = adapter;
597
+ this.changed();
592
598
  await this.refresh();
593
599
  }
594
600
  async unlock(password) {
@@ -904,13 +910,50 @@ var ArkadeAdapter = class ArkadeAdapter {
904
910
  async balance() {
905
911
  return (await this.wallet.getBalance()).available;
906
912
  }
907
- /** Sats whose batch expired before they were renewed: still this wallet's, but only after a recovery. */
908
- async recoverable() {
909
- return Number((await this.wallet.getBalance()).recoverable ?? 0);
913
+ /**
914
+ * Sats whose batch expired before they were renewed, in three kinds, as a person can act on them:
915
+ * - `sweeping`: past their expiry, but the server has not swept their batch yet (with any swept ones: none can be
916
+ * recovered meanwhile). The SDK (0.4.76) counts them as
917
+ * recoverable already, but it settles an unswept coin without the forfeit the server still asks for (arkd wants
918
+ * one until the sweep): the batch fails ("missing forfeit transactions") and the server bans the coin for a while.
919
+ * The sweep waits for the chain's time to pass the expiry: an hour or more on a real network, the expiry's blocks
920
+ * on regtest.
921
+ * - `recoverable`: swept, and a recovery takes them now.
922
+ * - `small`: swept, but together under the server's dust limit: a recovery makes one new coin and the server makes
923
+ * none that small, so `recoverVtxos` answers "No recoverable VTXOs found". They wait for more coins to expire.
924
+ */
925
+ async expired() {
926
+ const now = { timestamp: /* @__PURE__ */ new Date() };
927
+ let swept = 0, sweeping = 0;
928
+ for (const vtxo of await this.wallet.getVtxos({
929
+ withRecoverable: true,
930
+ withUnrolled: false
931
+ })) if (isRecoverable(vtxo)) swept += vtxo.value;
932
+ else if (!vtxo.isSwept && canRecoverOnchain(vtxo, now)) sweeping += vtxo.value;
933
+ const takes = !sweeping && swept > 0 && (await (await this.wallet.getVtxoManager()).getRecoverableBalance()).recoverable >= this.wallet.dustAmount;
934
+ return sweeping ? {
935
+ recoverable: 0,
936
+ sweeping: sweeping + swept,
937
+ small: 0
938
+ } : {
939
+ recoverable: takes ? swept : 0,
940
+ sweeping: 0,
941
+ small: takes ? 0 : swept
942
+ };
910
943
  }
911
- /** Moves expired (swept) outputs back into the balance, through the server's next batch. */
944
+ /**
945
+ * Moves swept outputs back into the balance, through the server's next batch. Refused while any expired coin is
946
+ * not swept yet (`recoverVtxos` would name it too, and that batch fails for all of them), and when what is swept
947
+ * is too little for a batch.
948
+ */
912
949
  recover() {
913
- return this.serial(async () => (await this.wallet.getVtxoManager()).recoverVtxos());
950
+ return this.serial(async () => {
951
+ const { recoverable, sweeping, small } = await this.expired();
952
+ if (sweeping) throw new Error("Expired coins can be recovered once the Ark server has swept their batch. Try again later.");
953
+ if (small) throw new Error("Too few expired sats to recover on their own. They are recovered with the next coins that expire.");
954
+ if (!recoverable) throw new Error("Nothing to recover");
955
+ return (await this.wallet.getVtxoManager()).recoverVtxos();
956
+ });
914
957
  }
915
958
  /** On-chain deposits land here; the SDK's settlement moves them into Ark once confirmed. */
916
959
  boardingAddress() {
@@ -1510,7 +1553,11 @@ var ArkWallet = class {
1510
1553
  }
1511
1554
  const balance = await read("balance", () => adapter.balance(), this.view.balance);
1512
1555
  const incoming = await read("incoming", () => adapter.incoming(), this.view.incoming);
1513
- const recoverable = await read("recoverable", () => adapter.recoverable(), this.view.recoverable);
1556
+ const { recoverable, sweeping, small } = await read("recoverable", () => adapter.expired(), {
1557
+ recoverable: this.view.recoverable,
1558
+ sweeping: this.view.sweeping,
1559
+ small: this.view.small
1560
+ });
1514
1561
  if (this.adapter !== adapter) return;
1515
1562
  this.view = {
1516
1563
  configured: true,
@@ -1523,12 +1570,14 @@ var ArkWallet = class {
1523
1570
  incoming,
1524
1571
  balance,
1525
1572
  recoverable,
1573
+ sweeping,
1574
+ small,
1526
1575
  error: failed.length ? `Could not read the ${failed.join(", ")} from the Ark provider. Last values may be stale.` : void 0
1527
1576
  };
1528
1577
  this.changed();
1529
1578
  if (this.adapter) this.timer = setTimeout(() => void this.refresh(), 1e4);
1530
1579
  }
1531
- /** Expired outputs back into the balance; the next refresh shows them once the batch is done. */
1580
+ /** Swept outputs back into the balance (refused while expired ones wait for the sweep); the next refresh shows them. */
1532
1581
  async recover() {
1533
1582
  const txid = await this.require().recover();
1534
1583
  await this.refresh();
@@ -2355,7 +2404,7 @@ var NOTES_REFUND_SECS = 604800;
2355
2404
  var MAX_SATS = 1e7;
2356
2405
  var fedimintMode = (network) => network === "bitcoin" ? "mainnet" : "testnet";
2357
2406
  var key = (mode) => `fedimintWallet-${mode}`;
2358
- var sats$7 = (msats) => Math.floor(msats / 1e3);
2407
+ var sats$6 = (msats) => Math.floor(msats / 1e3);
2359
2408
  function assertFedimintAmount(amount) {
2360
2409
  if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error("Enter an amount in sats");
2361
2410
  if (amount > MAX_SATS) throw new Error(`Amounts above ${MAX_SATS.toLocaleString()} sats are not supported`);
@@ -2363,7 +2412,7 @@ function assertFedimintAmount(amount) {
2363
2412
  /** An invite code as the client takes it: bech32m with the prefix `fed1` (so `fed11…`), one line, bounded. */
2364
2413
  function normalizeInvite(text) {
2365
2414
  const invite = text.trim();
2366
- if (!/^fed11[02-9ac-hj-np-z]{20,2000}$/i.test(invite)) throw new Error("That is not a Fedimint invite code: it starts with fed1");
2415
+ if (!/^fed11[02-9ac-hj-np-z]{20,2000}$/i.test(invite)) throw engineError("fedimintNotInvite");
2367
2416
  return invite.toLowerCase();
2368
2417
  }
2369
2418
  /** A client operation as a line of history. */
@@ -2378,21 +2427,21 @@ function historyEntry(federation, op) {
2378
2427
  };
2379
2428
  const invoiceSats = () => {
2380
2429
  const decoded = op.invoice ? decodeBolt11(op.invoice) : null;
2381
- return decoded?.amountMsat ? sats$7(Number(decoded.amountMsat)) : void 0;
2430
+ return decoded?.amountMsat ? sats$6(Number(decoded.amountMsat)) : void 0;
2382
2431
  };
2383
2432
  if (op.kind === "mint" && op.variant === "spend_o_o_b") {
2384
2433
  const state = text === "UserCanceledSuccess" || text === "Refunded" ? "taken-back" : text === "UserCanceledFailure" || text === "Success" ? "done" : "pending";
2385
2434
  return {
2386
2435
  ...base,
2387
2436
  kind: "notes-out",
2388
- amount: op.amountMsats !== void 0 ? sats$7(op.amountMsats) : void 0,
2437
+ amount: op.amountMsats !== void 0 ? sats$6(op.amountMsats) : void 0,
2389
2438
  state
2390
2439
  };
2391
2440
  }
2392
2441
  if (op.kind === "mint" && op.variant === "reissuance") return {
2393
2442
  ...base,
2394
2443
  kind: "notes-in",
2395
- amount: op.amountMsats !== void 0 ? sats$7(op.amountMsats) : void 0,
2444
+ amount: op.amountMsats !== void 0 ? sats$6(op.amountMsats) : void 0,
2396
2445
  state: text === "Done" ? "done" : text === "Failed" ? "failed" : "pending"
2397
2446
  };
2398
2447
  if (op.kind === "ln" && op.variant === "receive") return {
@@ -2414,7 +2463,7 @@ function historyEntry(federation, op) {
2414
2463
  if (op.kind === "wallet") return {
2415
2464
  ...base,
2416
2465
  kind: "onchain",
2417
- amount: op.amountMsats !== void 0 ? sats$7(op.amountMsats) : void 0,
2466
+ amount: op.amountMsats !== void 0 ? sats$6(op.amountMsats) : void 0,
2418
2467
  state: "pending"
2419
2468
  };
2420
2469
  }
@@ -2547,7 +2596,7 @@ var FedimintWallet = class {
2547
2596
  render() {
2548
2597
  const federations = (this.saved?.federations ?? []).map(({ database: _database, ...f }) => ({
2549
2598
  ...f,
2550
- balance: sats$7(this.balances.get(f.id) ?? 0),
2599
+ balance: sats$6(this.balances.get(f.id) ?? 0),
2551
2600
  status: this.clients.has(f.id) ? "ready" : this.problems.has(f.id) ? "error" : "connecting",
2552
2601
  error: this.problems.get(f.id),
2553
2602
  lightning: f.modules.includes("ln")
@@ -2586,7 +2635,7 @@ var FedimintWallet = class {
2586
2635
  if (!isFederationId(info.federationId)) throw new Error("The federation answered with an invalid id");
2587
2636
  if (!info.modules.includes("mint")) throw new Error("This federation has no ecash module this app can use (the v1 mint module)");
2588
2637
  if (!info.network) throw new Error("Could not tell which Bitcoin network this federation is on");
2589
- if (fedimintMode(info.network) !== this.network) throw new WrongNetworkError(fedimintMode(info.network), fedimintMode(info.network) === "mainnet" ? "This federation holds real bitcoin: it belongs in a Mainnet Fedimint wallet" : `This federation is on ${info.network}, a test network: it belongs in a Testnet Fedimint wallet`);
2638
+ if (fedimintMode(info.network) !== this.network) throw new WrongNetworkError(fedimintMode(info.network), fedimintMode(info.network) === "mainnet" ? engineText("fedimintRealOnTestnet") : engineText("fedimintTestOnMainnet", { chain: info.network }));
2590
2639
  }
2591
2640
  /**
2592
2641
  * Joins a federation. `recover`: the mnemonic was used with this federation before (a restore): the client rebuilds
@@ -2599,7 +2648,7 @@ var FedimintWallet = class {
2599
2648
  const info = await this.gate.within(sdk.preview(code));
2600
2649
  this.checkFederation(info);
2601
2650
  const saved = this.saved ?? await this.newSeed();
2602
- if (saved.federations.some((f) => f.id === info.federationId)) throw new Error("You already joined this federation");
2651
+ if (saved.federations.some((f) => f.id === info.federationId)) throw engineError("fedimintAlreadyJoined");
2603
2652
  if (saved.federations.length >= 8) throw new Error(`At most 8 federations per wallet`);
2604
2653
  const database = fedimintDatabase(crypto.randomUUID());
2605
2654
  const mnemonic = await unsealSeed(saved.seed, saved.deviceKey);
@@ -2669,7 +2718,7 @@ var FedimintWallet = class {
2669
2718
  if (!saved || !federation) throw new Error("Unknown federation");
2670
2719
  const client = this.clients.get(federationId);
2671
2720
  if (!client) throw new Error("Wait for this federation to connect: its balance must be read before leaving it");
2672
- if (sats$7(await client.balance()) > 0) throw new Error("This federation still holds your sats: spend or move them first");
2721
+ if (sats$6(await client.balance()) > 0) throw new Error("This federation still holds your sats: spend or move them first");
2673
2722
  if ((await intentRepository.list()).some((i) => i.review.method === "fedimint" && i.review.provider === federationId && [
2674
2723
  "pending",
2675
2724
  "submitted",
@@ -2677,7 +2726,7 @@ var FedimintWallet = class {
2677
2726
  ].includes(i.review.state))) throw new Error("A payment through this federation is not finished yet");
2678
2727
  const out = (await client.operations(100)).filter((op) => historyEntry(federationId, op)?.kind === "notes-out" && historyEntry(federationId, op)?.state === "pending");
2679
2728
  for (const op of out) if (await this.takeBack(federationId, op.id, 15e3) === "pending") throw new Error("Notes you sent from this federation are still being settled: try again in a moment");
2680
- if (sats$7(await client.balance()) > 0) throw new Error("Notes nobody redeemed came back to this federation: spend or move them first");
2729
+ if (sats$6(await client.balance()) > 0) throw new Error("Notes nobody redeemed came back to this federation: spend or move them first");
2681
2730
  this.unsubscribe.get(federationId)?.();
2682
2731
  this.unsubscribe.delete(federationId);
2683
2732
  this.clients.delete(federationId);
@@ -2731,7 +2780,7 @@ var FedimintWallet = class {
2731
2780
  const client = this.clients.get(federationId);
2732
2781
  if (client) return client;
2733
2782
  if (!this.saved?.federations.some((f) => f.id === federationId)) throw new Error(`You have not joined this federation in your ${this.network === "mainnet" ? "Mainnet" : "Testnet"} Fedimint wallet`);
2734
- throw new Error(this.problems.get(federationId) ?? "Wait for the federation to connect");
2783
+ throw new Error(this.problems.get(federationId) ?? engineText("fedimintConnecting"));
2735
2784
  }
2736
2785
  federation(federationId) {
2737
2786
  return this.saved?.federations.find((f) => f.id === federationId);
@@ -2748,7 +2797,7 @@ var FedimintWallet = class {
2748
2797
  async spendNotes(federationId, amount, ghostly = `wallet-${crypto.randomUUID()}`) {
2749
2798
  assertFedimintAmount(amount);
2750
2799
  const client = this.client(federationId);
2751
- if (sats$7(await client.balance()) < amount) throw new Error("Not enough in this federation");
2800
+ if (sats$6(await client.balance()) < amount) throw engineError("fedimintNotEnough");
2752
2801
  const spent = await client.spend(amount * 1e3, {
2753
2802
  cancelAfterSecs: NOTES_REFUND_SECS,
2754
2803
  ghostly
@@ -2793,7 +2842,7 @@ var FedimintWallet = class {
2793
2842
  if (state !== "done") throw new Error("The federation is still redeeming these notes: they will show in the balance");
2794
2843
  return {
2795
2844
  federation: found.federation,
2796
- amount: sats$7(found.amountMsats)
2845
+ amount: sats$6(found.amountMsats)
2797
2846
  };
2798
2847
  }
2799
2848
  /** Takes notes back. `canceled`: they are ours again; `taken`: the recipient redeemed them first; `pending`: not settled yet. */
@@ -3156,12 +3205,12 @@ async function openBreez(params, sdk = loadBreezSdk, storage = breezStorage) {
3156
3205
  /** Payments read to find one: newest first, bounded. */
3157
3206
  var PAGE$1 = 100;
3158
3207
  var PAGES$1 = 5;
3159
- var sats$6 = (value) => {
3208
+ var sats$5 = (value) => {
3160
3209
  const n = Number(value);
3161
3210
  if (!Number.isSafeInteger(n) || n < 0) throw new Error("Spark returned an invalid amount");
3162
3211
  return n;
3163
3212
  };
3164
- var message$4 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : "Spark refused the payment";
3213
+ var message$5 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : "Spark refused the payment";
3165
3214
  var invoiceOf = (p) => p.details?.type === "spark" ? p.details.invoiceDetails?.invoice : void 0;
3166
3215
  /** A fresh RFC 4122 v4 UUID: the SDK wants its idempotency keys in that form. */
3167
3216
  var newSparkKey = () => crypto.randomUUID();
@@ -3218,7 +3267,7 @@ var SparkAdapter = class SparkAdapter {
3218
3267
  return paymentRequest;
3219
3268
  }
3220
3269
  async balance() {
3221
- return sats$6((await this.wallet.getInfo({ ensureSynced: false })).balanceSats);
3270
+ return sats$5((await this.wallet.getInfo({ ensureSynced: false })).balanceSats);
3222
3271
  }
3223
3272
  /** Asks Spark's operators for what changed (transfers to claim, payments that completed). */
3224
3273
  async sync() {
@@ -3232,8 +3281,8 @@ var SparkAdapter = class SparkAdapter {
3232
3281
  return payments.map((p) => ({
3233
3282
  id: p.id,
3234
3283
  direction: p.paymentType === "send" ? "out" : "in",
3235
- amount: sats$6(p.amount),
3236
- fee: sats$6(p.fees),
3284
+ amount: sats$5(p.amount),
3285
+ fee: sats$5(p.fees),
3237
3286
  at: p.timestamp * 1e3,
3238
3287
  status: p.status,
3239
3288
  via: p.method === "spark" ? "spark" : p.method === "lightning" ? "lightning" : p.method === "deposit" || p.method === "withdraw" ? "onchain" : "other",
@@ -3275,7 +3324,7 @@ var SparkAdapter = class SparkAdapter {
3275
3324
  if (await this.balance() < review.amount + review.fee) throw new Error("Spark balance changed. Create a new review");
3276
3325
  if (!persist) throw new Error("Durable payment journal required");
3277
3326
  } catch (error) {
3278
- throw new PaymentPreflightError(message$4(error));
3327
+ throw new PaymentPreflightError(message$5(error));
3279
3328
  }
3280
3329
  await persist();
3281
3330
  try {
@@ -3287,7 +3336,7 @@ var SparkAdapter = class SparkAdapter {
3287
3336
  } catch (error) {
3288
3337
  const found = await this.sent(review, prepared).catch(() => void 0);
3289
3338
  if (found) return this.outcome(found);
3290
- throw new Error(/insufficient/i.test(message$4(error)) ? "Insufficient Spark balance" : "Spark payment outcome unknown");
3339
+ throw new Error(/insufficient/i.test(message$5(error)) ? "Insufficient Spark balance" : "Spark payment outcome unknown");
3291
3340
  }
3292
3341
  });
3293
3342
  }
@@ -3316,7 +3365,7 @@ var SparkAdapter = class SparkAdapter {
3316
3365
  limit: PAGE$1,
3317
3366
  sortAscending: false
3318
3367
  });
3319
- const found = payments.find((p) => p.status === "completed" && invoiceOf(p) === invoice && sats$6(p.amount) >= amount && !claimed.has(p.id));
3368
+ const found = payments.find((p) => p.status === "completed" && invoiceOf(p) === invoice && sats$5(p.amount) >= amount && !claimed.has(p.id));
3320
3369
  if (found) return found.id;
3321
3370
  if (payments.length < PAGE$1) break;
3322
3371
  }
@@ -3346,12 +3395,12 @@ var SparkAdapter = class SparkAdapter {
3346
3395
  } else if (method.type === "sparkInvoice") {
3347
3396
  const details = method.sparkInvoiceDetails;
3348
3397
  if (details.invoice !== address || details.tokenIdentifier) throw new Error("Spark prepared a payment to another invoice");
3349
- if (details.amount !== void 0 && sats$6(details.amount) !== amount) throw new Error("The Spark invoice is for another amount");
3398
+ if (details.amount !== void 0 && sats$5(details.amount) !== amount) throw new Error("The Spark invoice is for another amount");
3350
3399
  if (details.expiryTime !== void 0 && details.expiryTime * 1e3 < Date.now()) throw new Error("The Spark invoice has expired");
3351
3400
  } else throw new Error("Spark would not pay this as a Spark transfer");
3352
- if (sats$6(quote.amount) !== amount) throw new Error("Spark prepared another amount");
3401
+ if (sats$5(quote.amount) !== amount) throw new Error("Spark prepared another amount");
3353
3402
  return {
3354
- fee: sats$6(method.fee),
3403
+ fee: sats$5(method.fee),
3355
3404
  kind: method.type === "sparkAddress" ? "address" : "invoice"
3356
3405
  };
3357
3406
  }
@@ -3755,12 +3804,12 @@ var PaymentCoordinator = class {
3755
3804
  const validated = validatePaymentTarget(target);
3756
3805
  if (validated.method === "usdt") assertTokenUnits(amount);
3757
3806
  else assertWholeSats(amount);
3758
- if (!Number.isSafeInteger(feeCap) || feeCap < 0) throw new Error("Invalid maximum fee");
3807
+ if (!Number.isSafeInteger(feeCap) || feeCap < 0) throw engineError("invalidMaxFee");
3759
3808
  if (context.requestId) {
3760
- if ((await this.repository.list()).find(({ review: r }) => r.requestId === context.requestId && r.linkId === context.linkId && !["failed", "cancelled"].includes(r.state))) throw new Error("This request already has a payment. Reconcile it before trying again.");
3809
+ if ((await this.repository.list()).find(({ review: r }) => r.requestId === context.requestId && r.linkId === context.linkId && !["failed", "cancelled"].includes(r.state))) throw engineError("requestHasPayment");
3761
3810
  }
3762
3811
  const { fee, prepared, evm } = await this.adapter(validated.method).prepare(validated, amount, feeCap);
3763
- if (!Number.isSafeInteger(fee) || fee < 0 || fee > feeCap) throw new Error("The fee exceeds your limit");
3812
+ if (!Number.isSafeInteger(fee) || fee < 0 || fee > feeCap) throw engineError("feeAboveLimit");
3764
3813
  const review = {
3765
3814
  ...validated,
3766
3815
  ...context,
@@ -3782,7 +3831,7 @@ var PaymentCoordinator = class {
3782
3831
  approve(id) {
3783
3832
  return this.once(id, async () => {
3784
3833
  const saved = await this.require(id);
3785
- if (saved.review.state !== "pending") throw new Error("This payment cannot be submitted again. Reconcile its existing transaction.");
3834
+ if (saved.review.state !== "pending") throw engineError("cannotSubmitAgain");
3786
3835
  validatePaymentTarget(saved.review);
3787
3836
  saved.review = (await this.repository.claim(id)).review;
3788
3837
  this.changed(saved.review);
@@ -3797,7 +3846,7 @@ var PaymentCoordinator = class {
3797
3846
  saved.review = {
3798
3847
  ...saved.review,
3799
3848
  state: error instanceof PaymentPreflightError ? "failed" : "unknown",
3800
- error: error instanceof PaymentPreflightError ? error.message : "Outcome unknown. Check the existing payment; do not send another."
3849
+ error: error instanceof PaymentPreflightError ? error.message : engineText("outcomeUnknown")
3801
3850
  };
3802
3851
  }
3803
3852
  await this.repository.put(saved);
@@ -3815,13 +3864,13 @@ var PaymentCoordinator = class {
3815
3864
  ...saved.review,
3816
3865
  ...result,
3817
3866
  state: result.failed ? "failed" : result.settled ? "settled" : result.pending ? "submitted" : "unknown",
3818
- error: result.failed ? result.error : result.settled ? void 0 : "Not yet confirmed. No second payment was sent."
3867
+ error: result.failed ? result.error : result.settled ? void 0 : engineText("notYetConfirmed")
3819
3868
  };
3820
3869
  } catch {
3821
3870
  saved.review = {
3822
3871
  ...saved.review,
3823
3872
  state: "unknown",
3824
- error: "Could not verify this payment yet. No second payment was sent."
3873
+ error: engineText("couldNotVerify")
3825
3874
  };
3826
3875
  }
3827
3876
  await this.repository.put(saved);
@@ -3837,14 +3886,31 @@ var PaymentCoordinator = class {
3837
3886
  return saved.review;
3838
3887
  });
3839
3888
  }
3889
+ /**
3890
+ * Cancels the reviews no Approve can send any more, as Cancel does (what prepare reserved goes back, and each stays
3891
+ * in the store as cancelled): one past its expiry (approve refuses it), and one whose request was paid some other
3892
+ * way or closed (`unpayable`). Only pending reviews: a submitted or unknown one may hold or move money, and is only
3893
+ * ever reconciled. Returns what it cancelled.
3894
+ */
3895
+ async dropStale(now = Date.now(), unpayable = () => false) {
3896
+ const dropped = [];
3897
+ for (const { review } of await this.repository.list()) {
3898
+ if (review.state !== "pending" || review.expiresAt > now && !unpayable(review)) continue;
3899
+ try {
3900
+ const cancelled = await this.cancel(review.id);
3901
+ if (cancelled.state === "cancelled") dropped.push(cancelled);
3902
+ } catch {}
3903
+ }
3904
+ return dropped;
3905
+ }
3840
3906
  adapter(method) {
3841
3907
  const adapter = this.adapters.find((a) => a.method === method);
3842
- if (!adapter) throw new Error("This payment method is unavailable");
3908
+ if (!adapter) throw engineError("methodUnavailable");
3843
3909
  return adapter;
3844
3910
  }
3845
3911
  async require(id) {
3846
3912
  const saved = await this.repository.get(id);
3847
- if (!saved) throw new Error("Unknown payment intent");
3913
+ if (!saved) throw engineError("unknownPaymentIntent");
3848
3914
  return saved;
3849
3915
  }
3850
3916
  once(id, work) {
@@ -3857,6 +3923,22 @@ var PaymentCoordinator = class {
3857
3923
  };
3858
3924
  //#endregion
3859
3925
  //#region ../browser/src/engine/paymentAdapters/cashu.ts
3926
+ /**
3927
+ * How long one request to a mint may last: cashu-ts's default `requestTimeout` (5 min), which this wallet does not
3928
+ * change. Retries of a swap (NUT-19) run inside the same approval, and reconcile waits for it (the coordinator runs one
3929
+ * operation per payment at a time).
3930
+ */
3931
+ var CASHU_REQUEST_TIMEOUT_MS = 3e5;
3932
+ /** The margin past that for a mint slow to act on a swap it did receive. */
3933
+ var SWAP_SETTLED_MS = 12e4;
3934
+ /**
3935
+ * When a swap whose outcome is unknown can no longer reach the mint: a request's longest life after the approval
3936
+ * (`submittedAt`, saved when it was claimed), and the margin after the attempt ended (`attemptEndedAt`), both saved.
3937
+ */
3938
+ function swapSettledAt(review, prepared) {
3939
+ return Math.max((review.submittedAt ?? review.createdAt) + CASHU_REQUEST_TIMEOUT_MS + SWAP_SETTLED_MS, (prepared.attemptEndedAt ?? 0) + SWAP_SETTLED_MS);
3940
+ }
3941
+ var NEVER_REACHED_MINT = "The payment never reached the mint: nothing was sent, and the sats are back in your wallet. You can pay again.";
3860
3942
  var CashuAdapter = class {
3861
3943
  wallet;
3862
3944
  publish;
@@ -3868,21 +3950,45 @@ var CashuAdapter = class {
3868
3950
  async prepare(target, amount, feeCap) {
3869
3951
  if (target.method !== "cashu" || target.network !== (mintNetwork(target.provider) === "testnet" ? "cashu-test" : "bitcoin")) throw new Error("Cashu mint/network mismatch");
3870
3952
  const result = await this.wallet.prepareReviewedCashu(target.provider, amount);
3871
- if (result.fee > feeCap) throw new Error("Cashu fee exceeds your limit");
3953
+ if (result.fee > feeCap) throw engineError("cashuFeeAboveLimit");
3872
3954
  return result;
3873
3955
  }
3874
3956
  async execute(review, prepared, persist) {
3875
3957
  if (!review.linkId) throw new Error("Cashu requires an authenticated chat recipient");
3876
- const token = await this.wallet.executeReviewedCashu(review, prepared);
3958
+ let token;
3959
+ try {
3960
+ token = await this.wallet.executeReviewedCashu(review, prepared);
3961
+ } catch (error) {
3962
+ prepared.attemptEndedAt = Date.now();
3963
+ throw error;
3964
+ }
3877
3965
  await persist?.();
3878
3966
  await this.publish(review, token);
3879
3967
  return { settled: await this.wallet.reviewedCashuSpent(prepared) };
3880
3968
  }
3881
- async reconcile(review, prepared) {
3969
+ /** `persist` saves the intent as it is: required, since the abandon path must be durable before it releases anything. */
3970
+ async reconcile(review, prepared, persist) {
3971
+ if (prepared.abandoned) return this.abandon(prepared, persist);
3882
3972
  if (await this.wallet.reviewedCashuSpent(prepared)) return { settled: true };
3883
3973
  const token = prepared.token ?? await this.wallet.recoverReviewedCashu(review, prepared);
3884
3974
  if (token) await this.publish(review, token);
3885
- return { settled: await this.wallet.reviewedCashuSpent(prepared) };
3975
+ if (await this.wallet.reviewedCashuSpent(prepared)) return { settled: true };
3976
+ if (!token && Date.now() >= swapSettledAt(review, prepared) && await this.wallet.reviewedCashuNeverSwapped(prepared)) return this.abandon(prepared, persist);
3977
+ return { settled: false };
3978
+ }
3979
+ /**
3980
+ * The mint proved this swap never happened. Marked for good first (a reload repeats only this), then its reserved
3981
+ * inputs are released. Nothing sends the saved swap again: approve claims pending reviews only, and reconcile asks.
3982
+ */
3983
+ async abandon(prepared, persist) {
3984
+ prepared.abandoned = true;
3985
+ await persist();
3986
+ await this.wallet.releaseReviewedCashu(prepared);
3987
+ return {
3988
+ settled: false,
3989
+ failed: true,
3990
+ error: NEVER_REACHED_MINT
3991
+ };
3886
3992
  }
3887
3993
  };
3888
3994
  //#endregion
@@ -4085,12 +4191,12 @@ var PAGES = 10;
4085
4191
  /** Without any record of a payment, it is called failed only this long after its invoice expired. */
4086
4192
  var LOST_AFTER_MS = 36e5;
4087
4193
  var hex$8 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
4088
- var sats$5 = (value) => {
4194
+ var sats$4 = (value) => {
4089
4195
  const n = Number(value);
4090
4196
  if (!Number.isSafeInteger(n) || n < 0) throw new Error("Breez returned an invalid amount");
4091
4197
  return n;
4092
4198
  };
4093
- var message$3 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : "Breez refused the payment";
4199
+ var message$4 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : "Breez refused the payment";
4094
4200
  /**
4095
4201
  * The idempotency key of one attempt at paying one invoice: asking the SDK twice with it pays once.
4096
4202
  * A new attempt (after one that failed) gets a new key, or the SDK would only hand back the failure.
@@ -4146,7 +4252,7 @@ var BreezLightning = class BreezLightning {
4146
4252
  const { balanceSats } = await this.wallet.getInfo({ ensureSynced: false });
4147
4253
  return {
4148
4254
  network: this.network,
4149
- balance: sats$5(balanceSats)
4255
+ balance: sats$4(balanceSats)
4150
4256
  };
4151
4257
  }
4152
4258
  async createInvoice(amount, memo) {
@@ -4169,7 +4275,7 @@ var BreezLightning = class BreezLightning {
4169
4275
  const payment = await this.find("receive", invoice.paymentHash, invoice.invoice);
4170
4276
  if (payment?.status === "completed") return {
4171
4277
  state: "paid",
4172
- amount: sats$5(payment.amount)
4278
+ amount: sats$4(payment.amount)
4173
4279
  };
4174
4280
  if (payment?.status === "pending") return { state: "open" };
4175
4281
  return { state: invoice.expiresAt < Date.now() ? "expired" : "open" };
@@ -4200,9 +4306,9 @@ var BreezLightning = class BreezLightning {
4200
4306
  const fee = this.feeOf(prepared);
4201
4307
  if (fee > maxFee) throw new Error(`The Lightning fee (${fee} sats) is above your limit (${maxFee})`);
4202
4308
  const { balanceSats } = await this.wallet.getInfo({ ensureSynced: false });
4203
- if (sats$5(balanceSats) < amount + fee) throw new Error(`Not enough sats in the Breez wallet (${sats$5(balanceSats)}; ${amount + fee} needed)`);
4309
+ if (sats$4(balanceSats) < amount + fee) throw new Error(`Not enough sats in the Breez wallet (${sats$4(balanceSats)}; ${amount + fee} needed)`);
4204
4310
  } catch (error) {
4205
- throw isNothingSpentError(error) ? error : new NothingSpentError(message$3(error));
4311
+ throw isNothingSpentError(error) ? error : new NothingSpentError(message$4(error));
4206
4312
  }
4207
4313
  const { payment } = await this.wallet.sendPayment({
4208
4314
  prepareResponse: prepared,
@@ -4230,7 +4336,7 @@ var BreezLightning = class BreezLightning {
4230
4336
  const state = payment.status === "completed" ? "paid" : payment.status === "failed" ? "failed" : "pending";
4231
4337
  return {
4232
4338
  state,
4233
- fee: state === "paid" ? sats$5(payment.fees) : void 0,
4339
+ fee: state === "paid" ? sats$4(payment.fees) : void 0,
4234
4340
  preimage: preimageOf(payment)
4235
4341
  };
4236
4342
  }
@@ -4247,18 +4353,18 @@ var BreezLightning = class BreezLightning {
4247
4353
  } });
4248
4354
  const method = prepared.paymentMethod;
4249
4355
  if (method.type !== "bolt11Invoice") throw new Error("Breez would not pay this as a Lightning invoice");
4250
- if (method.invoiceDetails.paymentHash !== decodeBolt11(invoice)?.paymentHash || sats$5(prepared.amount) !== amount) throw new Error("Breez prepared a payment that does not match the invoice");
4356
+ if (method.invoiceDetails.paymentHash !== decodeBolt11(invoice)?.paymentHash || sats$4(prepared.amount) !== amount) throw new Error("Breez prepared a payment that does not match the invoice");
4251
4357
  return prepared;
4252
4358
  }
4253
4359
  feeOf(prepared) {
4254
4360
  if (prepared.paymentMethod.type !== "bolt11Invoice") throw new Error("Breez would not pay this as a Lightning invoice");
4255
- return sats$5(prepared.paymentMethod.lightningFeeSats);
4361
+ return sats$4(prepared.paymentMethod.lightningFeeSats);
4256
4362
  }
4257
4363
  /** `failed` is never an answer here: a payment the SDK gave up on is pending until `paymentStatus` says so. */
4258
4364
  result(payment) {
4259
4365
  if (payment.status === "completed") return {
4260
4366
  state: "paid",
4261
- fee: sats$5(payment.fees),
4367
+ fee: sats$4(payment.fees),
4262
4368
  preimage: preimageOf(payment),
4263
4369
  ref: payment.id
4264
4370
  };
@@ -4427,8 +4533,8 @@ var unhex = (text) => {
4427
4533
  if (!/^([0-9a-f]{2})+$/.test(text)) throw new Error("Not hex");
4428
4534
  return Uint8Array.from(text.match(/../g), (h) => parseInt(h, 16));
4429
4535
  };
4430
- var sats$4 = (amount) => Number(amount.to_sat());
4431
- var message$2 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : error?.message ? String(error.message) : String(error);
4536
+ var sats$3 = (amount) => Number(amount.to_sat());
4537
+ var message$3 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : error?.message ? String(error.message) : String(error);
4432
4538
  /** Checks the settings before anything is contacted. */
4433
4539
  function parseBdkSettings({ config, secrets }) {
4434
4540
  const network = config.network;
@@ -4502,7 +4608,7 @@ var Esplora = class Esplora {
4502
4608
  if (this.signal.aborted) throw error;
4503
4609
  if (timedOut) throw new SourceUnreachableError(`the Esplora server at ${this.host} did not answer in ${HTTP_TIMEOUT / 1e3} s`, error);
4504
4610
  if (this.local) throw new SourceUnreachableError(`nothing answers at ${this.host}: the local Esplora server is not running`, error);
4505
- throw new SourceUnreachableError(`the Esplora server at ${this.host} did not answer (${message$2(error)})`, error);
4611
+ throw new SourceUnreachableError(`the Esplora server at ${this.host} did not answer (${message$3(error)})`, error);
4506
4612
  }
4507
4613
  const reader = response.body?.getReader();
4508
4614
  let body = "";
@@ -4557,7 +4663,7 @@ var Esplora = class Esplora {
4557
4663
  if (errors.length < servers.length) return;
4558
4664
  if (signal.aborted) return reject(errors[0]);
4559
4665
  if (errors.every((e) => e instanceof SourceConfigError)) return reject(errors[0]);
4560
- reject(new SourceUnreachableError(`no public ${network} Esplora server answered (${servers.map((s) => new URL(s).host).join(", ")}): ${message$2(errors[0])}`, errors[0]));
4666
+ reject(new SourceUnreachableError(`no public ${network} Esplora server answered (${servers.map((s) => new URL(s).host).join(", ")}): ${message$3(errors[0])}`, errors[0]));
4561
4667
  });
4562
4668
  }
4563
4669
  });
@@ -4616,7 +4722,7 @@ var BdkOnchain = class BdkOnchain {
4616
4722
  try {
4617
4723
  parsed = parseBdkSettings(settings);
4618
4724
  } catch (error) {
4619
- throw new SourceConfigError(message$2(error), error);
4725
+ throw new SourceConfigError(message$3(error), error);
4620
4726
  }
4621
4727
  const { mnemonic, ...found } = parsed;
4622
4728
  const fetcher = deps.fetch ?? globalThis.fetch.bind(globalThis);
@@ -4633,7 +4739,7 @@ var BdkOnchain = class BdkOnchain {
4633
4739
  wallet = bdk.Wallet.create(chain, pair.external, pair.internal);
4634
4740
  publicDescriptor = wallet.public_descriptor("external");
4635
4741
  } catch (error) {
4636
- throw new SourceConfigError(`bad descriptor: the recovery phrase does not make a ${SCRIPT[config.script].label} wallet (${message$2(error)})`, error);
4742
+ throw new SourceConfigError(`bad descriptor: the recovery phrase does not make a ${SCRIPT[config.script].label} wallet (${message$3(error)})`, error);
4637
4743
  }
4638
4744
  const fingerprint = /\[([0-9a-f]{8})\//.exec(publicDescriptor)?.[1] ?? "";
4639
4745
  const key = `bdkWallet-${config.network}-${hex$7(sha256(new TextEncoder().encode(publicDescriptor))).slice(0, 32)}`;
@@ -4673,7 +4779,7 @@ var BdkOnchain = class BdkOnchain {
4673
4779
  this.syncedAt = Date.now();
4674
4780
  this.persist();
4675
4781
  })().catch((error) => {
4676
- throw Object.assign(/* @__PURE__ */ new Error(`Could not sync from Esplora: ${message$2(error)}`), { cause: error });
4782
+ throw Object.assign(/* @__PURE__ */ new Error(`Could not sync from Esplora: ${message$3(error)}`), { cause: error });
4677
4783
  }).finally(() => {
4678
4784
  this.syncing = void 0;
4679
4785
  });
@@ -4687,8 +4793,8 @@ var BdkOnchain = class BdkOnchain {
4687
4793
  await this.sync();
4688
4794
  const balance = this.wallet.balance;
4689
4795
  return {
4690
- confirmed: sats$4(balance.confirmed),
4691
- unconfirmed: sats$4(balance.trusted_pending) + sats$4(balance.untrusted_pending)
4796
+ confirmed: sats$3(balance.confirmed),
4797
+ unconfirmed: sats$3(balance.trusted_pending) + sats$3(balance.untrusted_pending)
4692
4798
  };
4693
4799
  }
4694
4800
  async prepareSend(request) {
@@ -4714,9 +4820,9 @@ var BdkOnchain = class BdkOnchain {
4714
4820
  } catch (error) {
4715
4821
  const data = error.data;
4716
4822
  if (error.code === bdk.BdkErrorCode.InsufficientFunds && data) throw Object.assign(/* @__PURE__ */ new Error(`Not enough confirmed sats: ${data.available ?? 0} available, ${data.needed ?? "more"} needed with the fee`), { cause: error });
4717
- throw Object.assign(/* @__PURE__ */ new Error(`Could not build the transaction: ${message$2(error)}`), { cause: error });
4823
+ throw Object.assign(/* @__PURE__ */ new Error(`Could not build the transaction: ${message$3(error)}`), { cause: error });
4718
4824
  }
4719
- const fee = sats$4(psbt.fee());
4825
+ const fee = sats$3(psbt.fee());
4720
4826
  if (fee > feeCap) throw new Error(`The fee (${fee} sats) is above your limit of ${feeCap}`);
4721
4827
  if (!this.wallet.sign(psbt, new bdk.SignOptions())) throw new Error("Could not sign the transaction");
4722
4828
  const tx = psbt.extract_tx();
@@ -4741,14 +4847,14 @@ var BdkOnchain = class BdkOnchain {
4741
4847
  check(tx, recipient, amount, fee) {
4742
4848
  let paid = 0, outgoing = 0;
4743
4849
  for (const output of tx.output) {
4744
- const value = sats$4(output.value);
4850
+ const value = sats$3(output.value);
4745
4851
  if (output.script_pubkey.to_hex_string() === recipient && value === amount && !paid) {
4746
4852
  paid++;
4747
4853
  continue;
4748
4854
  }
4749
4855
  if (!this.wallet.is_mine(output.script_pubkey)) outgoing++;
4750
4856
  }
4751
- if (paid !== 1 || outgoing !== 0 || sats$4(this.wallet.calculate_fee(tx)) !== fee) throw new Error("The signed transaction does not match the payment");
4857
+ if (paid !== 1 || outgoing !== 0 || sats$3(this.wallet.calculate_fee(tx)) !== fee) throw new Error("The signed transaction does not match the payment");
4752
4858
  }
4753
4859
  async estimateFeeRate() {
4754
4860
  try {
@@ -4774,7 +4880,7 @@ var BdkOnchain = class BdkOnchain {
4774
4880
  headers: { "content-type": "text/plain" }
4775
4881
  });
4776
4882
  } catch (error) {
4777
- throw Object.assign(/* @__PURE__ */ new Error(`No answer from the Esplora server: ${message$2(error)}`), { cause: error });
4883
+ throw Object.assign(/* @__PURE__ */ new Error(`No answer from the Esplora server: ${message$3(error)}`), { cause: error });
4778
4884
  }
4779
4885
  if (answer.status === 200 && answer.body.trim() === prepared.txid) return this.sent(tx, prepared.txid);
4780
4886
  if (answer.status === 200) throw new Error("The Esplora server answered with another transaction id");
@@ -4784,7 +4890,7 @@ var BdkOnchain = class BdkOnchain {
4784
4890
  try {
4785
4891
  known = await this.esplora.txStatus(prepared.txid);
4786
4892
  } catch (error) {
4787
- throw Object.assign(/* @__PURE__ */ new Error(`The node refused the transaction, and it could not be checked: ${message$2(error)}`), { cause: error });
4893
+ throw Object.assign(/* @__PURE__ */ new Error(`The node refused the transaction, and it could not be checked: ${message$3(error)}`), { cause: error });
4788
4894
  }
4789
4895
  if (known) return this.sent(tx, prepared.txid);
4790
4896
  this.unreserve(prepared.txid);
@@ -4838,10 +4944,10 @@ var BdkOnchain = class BdkOnchain {
4838
4944
  await this.sync();
4839
4945
  const tip = this.wallet.latest_checkpoint.height;
4840
4946
  return this.wallet.transactions().map((wtx) => {
4841
- const [sent, received] = [sats$4(this.wallet.sent_and_received(wtx.tx)[0]), sats$4(this.wallet.sent_and_received(wtx.tx)[1])];
4947
+ const [sent, received] = [sats$3(this.wallet.sent_and_received(wtx.tx)[0]), sats$3(this.wallet.sent_and_received(wtx.tx)[1])];
4842
4948
  let fee;
4843
4949
  if (sent > 0) try {
4844
- fee = sats$4(this.wallet.calculate_fee(wtx.tx));
4950
+ fee = sats$3(this.wallet.calculate_fee(wtx.tx));
4845
4951
  } catch {
4846
4952
  fee = void 0;
4847
4953
  }
@@ -4864,7 +4970,7 @@ var BdkOnchain = class BdkOnchain {
4864
4970
  const tip = this.wallet.latest_checkpoint.height;
4865
4971
  const paid = [];
4866
4972
  for (const wtx of this.wallet.transactions()) {
4867
- const amount = wtx.tx.output.reduce((sum, output) => sum + (output.script_pubkey.to_hex_string() === script ? sats$4(output.value) : 0), 0);
4973
+ const amount = wtx.tx.output.reduce((sum, output) => sum + (output.script_pubkey.to_hex_string() === script ? sats$3(output.value) : 0), 0);
4868
4974
  if (!amount) continue;
4869
4975
  const height = wtx.chain_position.anchor?.block_id.height;
4870
4976
  paid.push({
@@ -5406,7 +5512,7 @@ var refOf = (ref) => {
5406
5512
  if (!parsed?.op) throw new Error("Unknown Fedimint operation");
5407
5513
  return parsed;
5408
5514
  };
5409
- var message$1 = (error) => error instanceof Error ? error.message : String(error);
5515
+ var message$2 = (error) => error instanceof Error ? error.message : String(error);
5410
5516
  /** What the client says before any contract is funded: nothing left the wallet. */
5411
5517
  var BEFORE_FUNDING = /insufficient|not enough|no gateway|no available gateway|expired|invalid|amount|already paid|parse|decode/i;
5412
5518
  /**
@@ -5435,7 +5541,7 @@ var FedimintLightning = class {
5435
5541
  try {
5436
5542
  return this.wallet.client(this.federationId);
5437
5543
  } catch (error) {
5438
- throw new SourceUnreachableError(message$1(error), error);
5544
+ throw new SourceUnreachableError(message$2(error), error);
5439
5545
  }
5440
5546
  }
5441
5547
  async info() {
@@ -5487,12 +5593,12 @@ var FedimintLightning = class {
5487
5593
  const balance = Math.floor(await this.client().balance() / 1e3);
5488
5594
  if (balance < amount + fee) throw new Error(`Not enough in this federation (${balance.toLocaleString()} sats; ${(amount + fee).toLocaleString()} needed)`);
5489
5595
  } catch (error) {
5490
- throw isNothingSpentError(error) ? error : new NothingSpentError(message$1(error));
5596
+ throw isNothingSpentError(error) ? error : new NothingSpentError(message$2(error));
5491
5597
  }
5492
5598
  try {
5493
5599
  paid = await this.client().payInvoice(invoice, note?.slice(0, 64) ?? "lightning");
5494
5600
  } catch (error) {
5495
- if (BEFORE_FUNDING.test(message$1(error))) throw new NothingSpentError(message$1(error));
5601
+ if (BEFORE_FUNDING.test(message$2(error))) throw new NothingSpentError(message$2(error));
5496
5602
  throw error;
5497
5603
  }
5498
5604
  const ref = JSON.stringify({
@@ -7175,7 +7281,7 @@ function fetchTransport(url, macaroon, signal) {
7175
7281
  }
7176
7282
  };
7177
7283
  }
7178
- /** Desktop: Rust makes the request (src-tauri/src/lnd.rs), pinning the certificate when there is one. */
7284
+ /** Desktop: Rust makes the request (apps/desktop/src/lnd.rs), pinning the certificate when there is one. */
7179
7285
  function tauriTransport(invoke, url, macaroon, certificate) {
7180
7286
  let closed = false;
7181
7287
  return {
@@ -7215,11 +7321,11 @@ var describe = (error) => (error.message ?? "unknown error").slice(0, 200);
7215
7321
  * found" in front of the node says nothing about the payment, and must not become "nothing was spent".
7216
7322
  */
7217
7323
  var notInitiated = (error) => /payment isn't initiated/i.test(error.message ?? "");
7218
- var sats$3 = (value) => {
7324
+ var sats$2 = (value) => {
7219
7325
  const n = Number(value ?? 0);
7220
7326
  return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
7221
7327
  };
7222
- var feeOf = (p) => p.fee_msat !== void 0 ? Math.ceil(Number(p.fee_msat) / 1e3) : sats$3(p.fee_sat);
7328
+ var feeOf = (p) => p.fee_msat !== void 0 ? Math.ceil(Number(p.fee_msat) / 1e3) : sats$2(p.fee_sat);
7223
7329
  var FAILURES = {
7224
7330
  FAILURE_REASON_NO_ROUTE: "No route to the recipient within the fee limit",
7225
7331
  FAILURE_REASON_INSUFFICIENT_BALANCE: "Not enough outbound liquidity in the node's channels",
@@ -7266,7 +7372,7 @@ var LndLightning = class {
7266
7372
  const network = NETWORKS[chain.network ?? ""];
7267
7373
  if (!network) throw new Error(`The node runs on ${chain.network ?? "an unknown network"}, which Ghostly does not support`);
7268
7374
  let balance;
7269
- if (this.capabilities.balance) balance = sats$3((await this.call("GET", "/v1/balance/channels")).local_balance?.sat);
7375
+ if (this.capabilities.balance) balance = sats$2((await this.call("GET", "/v1/balance/channels")).local_balance?.sat);
7270
7376
  return {
7271
7377
  network,
7272
7378
  alias: info.alias?.slice(0, 64) || void 0,
@@ -7297,7 +7403,7 @@ var LndLightning = class {
7297
7403
  const found = await this.call("GET", `/v1/invoice/${invoice.paymentHash}`);
7298
7404
  if (found.state === "SETTLED") return {
7299
7405
  state: "paid",
7300
- amount: sats$3(found.amt_paid_sat)
7406
+ amount: sats$2(found.amt_paid_sat)
7301
7407
  };
7302
7408
  if (found.state === "CANCELED") return { state: "expired" };
7303
7409
  return { state: (found.creation_date && found.expiry ? (Number(found.creation_date) + Number(found.expiry)) * 1e3 : invoice.expiresAt) < Date.now() ? "expired" : "open" };
@@ -7460,7 +7566,7 @@ var REFUSED = /\b(user (rejected|denied|declined|cancell?ed)|(rejected|denied|de
7460
7566
  var NOT_SENT = /\b(no[ _-]?route|insufficient[ _-](local[ _-])?balance|not enough (balance|funds)|insufficient funds)\b/i;
7461
7567
  var hex$5 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
7462
7568
  var bytes = (value) => new Uint8Array((value.match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
7463
- var message = (error) => (error instanceof Error ? error.message : typeof error === "string" ? error : "no answer").slice(0, 200);
7569
+ var message$1 = (error) => (error instanceof Error ? error.message : typeof error === "string" ? error : "no answer").slice(0, 200);
7464
7570
  /** A preimage that is the one of this payment hash: proof the invoice was paid. */
7465
7571
  var provesPayment = (preimage, paymentHash) => typeof preimage === "string" && /^[0-9a-f]{64}$/i.test(preimage) && hex$5(sha256(bytes(preimage.toLowerCase()))) === paymentHash.toLowerCase();
7466
7572
  /** A network named by a wallet, in Ghostly's words. Anything else is no answer. */
@@ -7535,13 +7641,13 @@ var WeblnLightning = class WeblnLightning {
7535
7641
  if (answer && typeof answer === "object" && answer.enabled === false) throw new Error("not enabled");
7536
7642
  } catch (error) {
7537
7643
  if (signal?.aborted) throw error;
7538
- throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet refused the connection (${message(error)})`), { cause: error });
7644
+ throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet refused the connection (${message$1(error)})`), { cause: error });
7539
7645
  }
7540
7646
  const info = typeof webln.getInfo === "function" ? await within$1(() => webln.getInfo(), CALL_MS, "info", signal).catch(() => void 0) : void 0;
7541
7647
  const methods = Array.isArray(info?.methods) ? info.methods : void 0;
7542
7648
  const has = (method) => typeof webln[method] === "function" && (!methods || methods.includes(method));
7543
7649
  let balance = false;
7544
- if (has("getBalance")) balance = await within$1(() => webln.getBalance(), CALL_MS, "balance", signal).then((b) => sats$2(b) !== void 0, () => false);
7650
+ if (has("getBalance")) balance = await within$1(() => webln.getBalance(), CALL_MS, "balance", signal).then((b) => sats$1(b) !== void 0, () => false);
7545
7651
  const capabilities = {
7546
7652
  receive: has("makeInvoice"),
7547
7653
  send: has("sendPayment"),
@@ -7573,7 +7679,7 @@ var WeblnLightning = class WeblnLightning {
7573
7679
  return {
7574
7680
  network: this.network,
7575
7681
  alias: this.alias,
7576
- ...this.capabilities.balance ? { balance: sats$2(await within$1(() => this.webln.getBalance(), CALL_MS, "balance", this.signal)) ?? 0 } : {}
7682
+ ...this.capabilities.balance ? { balance: sats$1(await within$1(() => this.webln.getBalance(), CALL_MS, "balance", this.signal)) ?? 0 } : {}
7577
7683
  };
7578
7684
  }
7579
7685
  async createInvoice(amount, memo) {
@@ -7616,18 +7722,18 @@ var WeblnLightning = class WeblnLightning {
7616
7722
  if (!decoded?.paymentHash || decoded.amountSat === null) throw new Error("That is not an invoice this wallet can pay");
7617
7723
  if (!sameChain(this.network, decoded.network)) throw new Error(`That invoice is for ${decoded.network}, and this browser wallet is on ${this.network}`);
7618
7724
  if (this.capabilities.balance) {
7619
- const balance = sats$2(await within$1(() => this.webln.getBalance(), CALL_MS, "balance", this.signal));
7725
+ const balance = sats$1(await within$1(() => this.webln.getBalance(), CALL_MS, "balance", this.signal));
7620
7726
  if (balance !== void 0 && balance < decoded.amountSat) throw new Error(`Not enough in the browser wallet (${balance} sats)`);
7621
7727
  }
7622
7728
  } catch (error) {
7623
- throw new NothingSpentError(message(error));
7729
+ throw new NothingSpentError(message$1(error));
7624
7730
  }
7625
7731
  let answer;
7626
7732
  try {
7627
7733
  answer = await within$1(() => this.webln.sendPayment(decoded.invoice), PROMPT_MS, "payment", this.signal);
7628
7734
  } catch (error) {
7629
- if (!this.signal?.aborted && error instanceof Error && (REFUSED_NAMES.has(error.name) || REFUSED.test(error.message) || NOT_SENT.test(error.message))) throw new NothingSpentError(`The browser wallet did not pay: ${message(error)}`);
7630
- throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet did not say whether it paid (${message(error)})`), { cause: error });
7735
+ if (!this.signal?.aborted && error instanceof Error && (REFUSED_NAMES.has(error.name) || REFUSED.test(error.message) || NOT_SENT.test(error.message))) throw new NothingSpentError(`The browser wallet did not pay: ${message$1(error)}`);
7736
+ throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet did not say whether it paid (${message$1(error)})`), { cause: error });
7631
7737
  }
7632
7738
  if (!provesPayment(answer?.preimage, decoded.paymentHash)) throw new Error("The browser wallet answered without proof of payment");
7633
7739
  const fee = answer?.route?.total_fees;
@@ -7665,7 +7771,7 @@ var WeblnLightning = class WeblnLightning {
7665
7771
  }
7666
7772
  };
7667
7773
  /** A WebLN balance in whole sats, when it is one. Wallets that answer in a fiat currency have none here. */
7668
- function sats$2(balance) {
7774
+ function sats$1(balance) {
7669
7775
  const value = Number(balance?.balance);
7670
7776
  if (!Number.isFinite(value) || value < 0) return void 0;
7671
7777
  const currency = typeof balance?.currency === "string" ? balance.currency.trim().toLowerCase() : "sats";
@@ -8215,6 +8321,25 @@ var IdentityProofs = class {
8215
8321
  this.seeds.delete(id);
8216
8322
  }
8217
8323
  }
8324
+ /**
8325
+ * "Clear all data": every proof that was ever shared, and every removed one still being revoked, gets its revocation
8326
+ * record published once more, all at once. Nothing will republish them after the clear, so this is a last word for
8327
+ * contacts who check; proofs posted elsewhere (a Bluesky record, a Pubky file, a DNS record) are the person's to delete.
8328
+ */
8329
+ async revokeAll() {
8330
+ this.stop();
8331
+ const shared = new Set(this.host.linkIds().flatMap((l) => this.host.ledger(l)?.shared.map((s) => s.id) ?? []));
8332
+ const at = now();
8333
+ const last = [...this.revocations.filter((r) => r.expiresAt > at), ...this.proofs.filter((p) => shared.has(identityStatement(p.binding).id)).map((p) => ({
8334
+ id: identityStatement(p.binding).id,
8335
+ key: p.binding.key,
8336
+ seed: p.seed,
8337
+ revokedAt: at,
8338
+ expiresAt: Math.min(p.binding.expiresAt, p.verified.expiresAt ?? Infinity)
8339
+ }))];
8340
+ await Promise.allSettled(last.map((r) => this.publishRevocation(r)));
8341
+ this.stop();
8342
+ }
8218
8343
  async publishRevocation(r) {
8219
8344
  if (!this.host.online()) return;
8220
8345
  await this.host.publish(await this.seedOf(r.id, r.seed), [{
@@ -8559,6 +8684,18 @@ var ProfileDid = class {
8559
8684
  }
8560
8685
  this.host.emit();
8561
8686
  }
8687
+ /**
8688
+ * "Clear all data": the DID ends. When a document was ever published, a newer packet reading `deactivated` replaces
8689
+ * it, so resolvers say the DID is gone instead of showing the old document until it expires. Stops publishing first.
8690
+ */
8691
+ async deactivate() {
8692
+ this.stop();
8693
+ const previous = this.stored?.published;
8694
+ if (!this.stored || !previous) return;
8695
+ const seq = Math.max(Math.floor(Date.now() / 1e3), previous.seq + 1);
8696
+ const identity = identityFromSeed(fromBase64Url(await unsealSeed(this.stored.seed.sealed, this.stored.seed.deviceKey)));
8697
+ await this.host.publish(toZ32(this.publicKey()), signDidDhtPacket(identity, encodeDidDhtDeactivated(this.publicKey()), seq));
8698
+ }
8562
8699
  listed() {
8563
8700
  return this.stored?.listed ?? [];
8564
8701
  }
@@ -11605,9 +11742,10 @@ var FileDesk = class {
11605
11742
  await chat.saving;
11606
11743
  const stored = await fileStore.get(id);
11607
11744
  const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
11608
- const have = await bytes.size(id) ?? 0;
11745
+ const size = await bytes.size(id), have = size ?? 0;
11609
11746
  const offset = Math.min(have, record.confirmed);
11610
11747
  if (have > offset) await bytes.truncate(id, offset);
11748
+ if (size === null && record.file.size === 0) await bytes.append(id, 0, /* @__PURE__ */ new Uint8Array());
11611
11749
  if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
11612
11750
  const appender = new FileAppender(bytes, id, offset);
11613
11751
  return {
@@ -11687,7 +11825,9 @@ var FileDesk = class {
11687
11825
  });
11688
11826
  }).catch(() => {});
11689
11827
  }
11828
+ const offered = record.direction === "in" && !this.deps.transfers.has(chat.local.get(record.id));
11690
11829
  this.show(linkId, chat, record, transferred, progress);
11830
+ if (offered) this.deps.flush?.();
11691
11831
  const id = record.direction === "out" ? outgoingFileId(linkId, record.id) : chat.local.get(record.id);
11692
11832
  const now = Date.now();
11693
11833
  if (!progress || now - (chat.lastSaved.get(id) ?? 0) >= SAVE_EVERY_MS) {
@@ -11845,6 +11985,221 @@ var FileDesk = class {
11845
11985
  };
11846
11986
  }
11847
11987
  };
11988
+ //#endregion
11989
+ //#region ../browser/src/shared/backupReminder.ts
11990
+ /**
11991
+ * The wallets that hold their money on this device, and their copy. Lightning is left out (through the Cashu mints it
11992
+ * is Cashu's balance; through a node of one's own, the node keeps it), and so is on-chain Bitcoin (an external source).
11993
+ */
11994
+ var BACKUP_KIND = {
11995
+ cashu: "profile",
11996
+ usdt: "phrase",
11997
+ arkade: "phrase",
11998
+ bark: "phrase",
11999
+ spark: "phrase",
12000
+ fedimint: "phrase"
12001
+ };
12002
+ var asks = (w) => w.network === "mainnet" && BACKUP_KIND[w.type] !== void 0;
12003
+ var big = (text) => {
12004
+ try {
12005
+ return text === void 0 ? void 0 : BigInt(text);
12006
+ } catch {
12007
+ return;
12008
+ }
12009
+ };
12010
+ /**
12011
+ * The Mainnet balances the reminder watches, read from the wallets' views: only wallets that exist and say what they
12012
+ * hold (a locked or connecting one says nothing yet).
12013
+ */
12014
+ function mainnetBalances(wallets, view) {
12015
+ if (!view) return [];
12016
+ const out = [];
12017
+ for (const w of wallets) {
12018
+ if (!asks(w)) continue;
12019
+ let balance;
12020
+ switch (w.type) {
12021
+ case "cashu":
12022
+ balance = BigInt(Math.max(0, Math.floor(view.balance)));
12023
+ break;
12024
+ case "usdt":
12025
+ balance = view.usdt?.configured && !view.usdt.locked ? big(view.usdt.balance) : void 0;
12026
+ break;
12027
+ case "arkade":
12028
+ balance = view.ark?.configured && !view.ark.locked ? BigInt(Math.floor(view.ark.balance ?? 0)) : void 0;
12029
+ break;
12030
+ case "bark":
12031
+ balance = view.bark?.configured && !view.bark.locked ? BigInt(Math.floor(view.bark.balance ?? 0)) : void 0;
12032
+ break;
12033
+ case "spark":
12034
+ balance = view.spark?.configured && !view.spark.locked ? BigInt(Math.floor(view.spark.balance ?? 0)) : void 0;
12035
+ break;
12036
+ case "fedimint": balance = view.fedimint?.federations.length ? BigInt(Math.floor(view.fedimint.balance ?? 0)) : void 0;
12037
+ }
12038
+ if (balance !== void 0) out.push({
12039
+ id: w.id,
12040
+ type: w.type,
12041
+ network: w.network,
12042
+ balance
12043
+ });
12044
+ }
12045
+ return out;
12046
+ }
12047
+ /**
12048
+ * What the balances now change in the records: a wallet holding money for the first time is funded; one put off with
12049
+ * "Later" comes back when more money came in than it held then (and after a spend, the mark follows the balance down).
12050
+ * `changed` is false when nothing needs saving.
12051
+ */
12052
+ function observeBalances(records, balances, now) {
12053
+ let next = records, changed = false;
12054
+ const put = (id, record) => {
12055
+ next = {
12056
+ ...next,
12057
+ [id]: record
12058
+ };
12059
+ changed = true;
12060
+ };
12061
+ for (const { id, balance, ...w } of balances) {
12062
+ if (!asks(w)) continue;
12063
+ const record = next[id] ?? {};
12064
+ if (balance > 0n && record.funded === void 0) {
12065
+ put(id, {
12066
+ ...record,
12067
+ funded: now
12068
+ });
12069
+ continue;
12070
+ }
12071
+ const mark = big(record.laterBalance);
12072
+ if (record.later === void 0 || mark === void 0) continue;
12073
+ if (balance > mark) {
12074
+ const { later: _l, laterBalance: _b, ...rest } = record;
12075
+ put(id, rest);
12076
+ } else if (balance < mark) put(id, {
12077
+ ...record,
12078
+ laterBalance: balance.toString()
12079
+ });
12080
+ }
12081
+ return {
12082
+ records: next,
12083
+ changed
12084
+ };
12085
+ }
12086
+ /**
12087
+ * A copy was made. `phrase`: one wallet's phrase shown or its backup file made (`id`), at any time: the phrase does
12088
+ * not change with the money. `profile`: a profile backup, which holds every wallet as it is now: it counts for the
12089
+ * wallets that already hold money, not for money that comes later to a wallet that had none.
12090
+ */
12091
+ function markBackedUp(records, made, now) {
12092
+ if (made.kind === "phrase") return {
12093
+ ...records,
12094
+ [made.id]: {
12095
+ ...records[made.id],
12096
+ backedUp: now
12097
+ }
12098
+ };
12099
+ const next = { ...records };
12100
+ for (const [id, record] of Object.entries(records)) if (record.funded !== void 0 && record.backedUp === void 0) next[id] = {
12101
+ ...record,
12102
+ backedUp: now
12103
+ };
12104
+ return next;
12105
+ }
12106
+ /** "Later" on a wallet's reminder: put off until the next receive or `LATER_MS`, once; the second ends it. */
12107
+ function putOff(records, id, balance, now) {
12108
+ const record = records[id] ?? {};
12109
+ const laters = (record.laters ?? 0) + 1;
12110
+ return {
12111
+ ...records,
12112
+ [id]: {
12113
+ ...record,
12114
+ laters,
12115
+ later: now,
12116
+ laterBalance: balance.toString()
12117
+ }
12118
+ };
12119
+ }
12120
+ /** A wallet removed: its reminder goes with it (a new wallet of the kind has a new phrase, and asks again). */
12121
+ function forgetWallet(records, id) {
12122
+ if (!(id in records)) return records;
12123
+ const { [id]: _gone, ...rest } = records;
12124
+ return rest;
12125
+ }
12126
+ //#endregion
12127
+ //#region ../browser/src/shared/buttons.ts
12128
+ /** The buttons of a message, when it has any. */
12129
+ function messageButtons(message) {
12130
+ return message.card?.kind === "buttons" ? message.card : void 0;
12131
+ }
12132
+ /**
12133
+ * Whether a reply's text is a button's label (or id): ignoring case and spaces at the ends, as the sender's rule
12134
+ * compares labels when it refuses two a typed answer could not tell apart (`buttonLabelClash`).
12135
+ */
12136
+ var sameLabel = sameButtonText;
12137
+ /**
12138
+ * What a reply presses on the author's side, or nothing: the original is this side's own message with buttons still
12139
+ * open, the button is one of them (named by `b`, or, for a reply that lost it on the way (the DHT floor carries a
12140
+ * reply's id alone) or came from an app without buttons, a text that is a button's label or id), and the presser has
12141
+ * not already answered with a `once` button. A reply naming a button says its label: one whose text is something else
12142
+ * ("No" naming `yes`) is only a reply, so what the chat shows and what the author takes never differ. `history`: the chat's rows, the reply itself excluded or not.
12143
+ */
12144
+ function buttonPress(reply, original, history) {
12145
+ if (reply.sender !== "peer" || original.sender !== "me" || !reply.replyTo) return void 0;
12146
+ const card = messageButtons(original);
12147
+ if (!card || card.closed) return void 0;
12148
+ const { button, inferred } = repliedButton(card, reply.text, reply.replyTo.button);
12149
+ if (!button) return void 0;
12150
+ if (answered(card, original.id, history.filter((row) => row.id !== reply.id && row.sender === "peer" && row.member === reply.member))) return void 0;
12151
+ return {
12152
+ messageId: original.id,
12153
+ button: button.id,
12154
+ label: button.label,
12155
+ ...inferred && { inferred: true }
12156
+ };
12157
+ }
12158
+ /**
12159
+ * The button a reply to a question presses: the one `b` names when the text is its label, or, without `b`, the one
12160
+ * whose label or id the text is (`inferred`). The author's rule and the presser's own view both read replies so.
12161
+ */
12162
+ function repliedButton(card, text, named) {
12163
+ if (named) return { button: card.buttons.find((b) => b.id === named && sameLabel(b.label, text)) };
12164
+ const button = card.buttons.find((b) => sameLabel(b.label, text) || sameLabel(b.id, text));
12165
+ return button ? {
12166
+ button,
12167
+ inferred: true
12168
+ } : {};
12169
+ }
12170
+ /** Whether these rows (one person's) already answered the question with a `once` button. */
12171
+ function answered(card, messageId, rows) {
12172
+ return rows.some((row) => row.press?.messageId === messageId && !!card.buttons.find((b) => b.id === row.press.button)?.once);
12173
+ }
12174
+ /**
12175
+ * The buttons of a message as the reader's app shows them: which one this side pressed last (from its own replies),
12176
+ * and whether it may press again. Closed, or answered with a `once` button: no more presses. `chosen`: the bot's
12177
+ * answer when it said one, else this side's own last press. A reply typed in words that is a button's label or id
12178
+ * counts as its press, as the author's app takes it (`buttonPress`): "yes" answering a once question answers it here.
12179
+ */
12180
+ function buttonsState(message, ref, history) {
12181
+ const card = messageButtons(message);
12182
+ if (!card) return void 0;
12183
+ let mine, last = -Infinity, locked = false;
12184
+ for (const row of history) {
12185
+ if (row.sender !== "me" || !row.replyTo) continue;
12186
+ if (row.replyTo.messageId !== message.id && (!ref || row.replyTo.id !== ref)) continue;
12187
+ const { button } = repliedButton(card, row.text, row.replyTo.button);
12188
+ if (!button) continue;
12189
+ if (button.once) locked = true;
12190
+ if (row.timestamp >= last) {
12191
+ last = row.timestamp;
12192
+ mine = button.id;
12193
+ }
12194
+ }
12195
+ const open = message.sender !== "me" && !card.closed && !locked;
12196
+ return {
12197
+ card,
12198
+ ...mine && { mine },
12199
+ ...(card.chosen ?? mine) && { chosen: card.chosen ?? mine },
12200
+ open
12201
+ };
12202
+ }
11848
12203
  /** The notice an app sends when a chat first goes live (useChat): shown as a line, never edited. */
11849
12204
  var JOIN_NOTICE$1 = /^👋 (?:.+ )?joined$/;
11850
12205
  /** Whether a text reads as a join notice: the chat shows it as a line and takes the contact's name from it. */
@@ -11869,12 +12224,16 @@ function takesPeerEdit(message, text) {
11869
12224
  return message.sender === "peer" && !message.file && !message.paymentId && !message.event && !message.groupPay && !JOIN_NOTICE$1.test(message.text) && (text === void 0 || !JOIN_NOTICE$1.test(text));
11870
12225
  }
11871
12226
  /**
11872
- * The message after edit `seq`, made at `at`: the new text (and the preview that came with it, or none), the version it
11873
- * replaces kept in the history. A text that did not change adds no version.
12227
+ * The message after edit `seq`, made at `at`: the new text (and the preview and the status card that came with it, or
12228
+ * none: each belongs to its version), the version it replaces kept in the history. A text that did not change adds no
12229
+ * version. A status card's update adds none either (WISP 4xx · Status Cards): it replaces the card in place, and its
12230
+ * text is only the card's fallback, so a bot's thousands of updates keep one card and no trail of versions. Buttons
12231
+ * (WISP 4xx · Message Buttons) are the exception: their text is the question people answer, so a new one keeps the
12232
+ * version it replaces, and only a change of the buttons alone (the answer marked, closed) adds none.
11874
12233
  */
11875
12234
  function withEdit(message, edit) {
11876
12235
  const before = message.edit?.history ?? [];
11877
- const history = edit.text === message.text ? before : trimHistory([...before, {
12236
+ const history = edit.text === message.text || edit.card && edit.card.kind !== "buttons" ? before : trimHistory([...before, {
11878
12237
  at: message.edit?.at ?? message.timestamp,
11879
12238
  text: message.text
11880
12239
  }]);
@@ -11884,12 +12243,13 @@ function withEdit(message, edit) {
11884
12243
  history,
11885
12244
  ...edit.pending && { pending: true }
11886
12245
  };
11887
- const { preview: _old, ...rest } = message;
12246
+ const { preview: _old, card: _card, ...rest } = message;
11888
12247
  return {
11889
12248
  ...rest,
11890
12249
  text: edit.text,
11891
12250
  edit: next,
11892
- ...edit.preview && { preview: edit.preview }
12251
+ ...edit.preview && { preview: edit.preview },
12252
+ ...edit.card && { card: edit.card }
11893
12253
  };
11894
12254
  }
11895
12255
  function trimHistory(history) {
@@ -11935,6 +12295,8 @@ var Outbox = class {
11935
12295
  waiting = /* @__PURE__ */ new Map();
11936
12296
  attempts = /* @__PURE__ */ new Map();
11937
12297
  busy = /* @__PURE__ */ new Set();
12298
+ /** In flight when the chat reopened (`flush({ reopened })`): sent again on the new link once that send returns. */
12299
+ reopenedWhileBusy = /* @__PURE__ */ new Set();
11938
12300
  stopped = false;
11939
12301
  resender;
11940
12302
  policy;
@@ -11975,6 +12337,7 @@ var Outbox = class {
11975
12337
  async transmit(id, { manual = false } = {}) {
11976
12338
  if (this.stopped || this.busy.has(id) || this.receipts.has(id)) return;
11977
12339
  this.busy.add(id);
12340
+ let again;
11978
12341
  try {
11979
12342
  let message = (await this.store.read()).find((m) => m.id === id);
11980
12343
  if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
@@ -12013,6 +12376,11 @@ var Outbox = class {
12013
12376
  await this.requeue(id, error instanceof Error ? error.message : "Sending failed");
12014
12377
  } finally {
12015
12378
  this.busy.delete(id);
12379
+ again = this.reopenedWhileBusy.delete(id);
12380
+ }
12381
+ if (again) {
12382
+ const message = (await this.store.read()).find((m) => m.id === id);
12383
+ if (message) await this.again(message);
12016
12384
  }
12017
12385
  }
12018
12386
  /**
@@ -12040,16 +12408,21 @@ var Outbox = class {
12040
12408
  */
12041
12409
  async flush({ reopened = false } = {}) {
12042
12410
  if (this.stopped || !reopened && !this.waiting.size) return;
12043
- const due = (await this.store.read()).filter((m) => m.sender === "me" && m.via !== "hold" && (m.delivery === "queued" || m.delivery === "waiting" || reopened && m.delivery === "sent" && this.receipts.has(m.id))).sort((a, b) => a.timestamp - b.timestamp);
12044
- for (const message of due) {
12045
- if (message.delivery !== "sent") {
12046
- await this.attempt(message.id);
12047
- continue;
12048
- }
12049
- this.clear(message.id);
12050
- if (this.resender.ready(message)) await this.transmit(message.id);
12051
- else await this.requeue(message.id, "Connection closed before receipt. Delivery is unconfirmed.");
12411
+ if (reopened) for (const id of this.busy) this.reopenedWhileBusy.add(id);
12412
+ const due = (await this.store.read()).filter((m) => m.sender === "me" && m.via !== "hold" && !this.busy.has(m.id) && (m.delivery === "queued" || m.delivery === "waiting" || reopened && m.delivery === "sent" && this.receipts.has(m.id))).sort((a, b) => a.timestamp - b.timestamp);
12413
+ for (const message of due) await this.again(message);
12414
+ }
12415
+ /** A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry it. */
12416
+ async again(message) {
12417
+ if (message.sender !== "me" || message.via === "hold") return;
12418
+ if (message.delivery === "queued" || message.delivery === "waiting") {
12419
+ await this.attempt(message.id);
12420
+ return;
12052
12421
  }
12422
+ if (message.delivery !== "sent" || !this.receipts.has(message.id)) return;
12423
+ this.clear(message.id);
12424
+ if (this.resender.ready(message)) await this.transmit(message.id);
12425
+ else await this.requeue(message.id, "Connection closed before receipt. Delivery is unconfirmed.");
12053
12426
  }
12054
12427
  async disconnected() {
12055
12428
  const messages = await this.store.read();
@@ -12064,6 +12437,7 @@ var Outbox = class {
12064
12437
  }
12065
12438
  async stop() {
12066
12439
  this.stopped = true;
12440
+ this.reopenedWhileBusy.clear();
12067
12441
  for (const wait of this.waiting.values()) if (wait.timer) clearTimeout(wait.timer);
12068
12442
  this.waiting.clear();
12069
12443
  await this.disconnected();
@@ -12185,7 +12559,7 @@ var EditQueue = class {
12185
12559
  * goes again at once, with a fresh count of attempts.
12186
12560
  */
12187
12561
  flush({ reopened = false } = {}) {
12188
- if (reopened) this.sent.clear();
12562
+ if (reopened) this.reopened();
12189
12563
  if (this.flushing) {
12190
12564
  this.again = true;
12191
12565
  return this.flushing;
@@ -12202,6 +12576,14 @@ var EditQueue = class {
12202
12576
  })();
12203
12577
  return this.flushing;
12204
12578
  }
12579
+ /**
12580
+ * A new session: what the last one sent without a confirmation may go again at once, with a fresh count of attempts.
12581
+ * Said once, as the session opens and before anything goes on it: said again later, it would send a second time what
12582
+ * this session already sent (two flushes of one opening, each starting over, sent a restored question's edit twice).
12583
+ */
12584
+ reopened() {
12585
+ this.sent.clear();
12586
+ }
12205
12587
  /** The contact confirmed edit `seq` of my message with this wire id. */
12206
12588
  async received(wireId, seq) {
12207
12589
  const row = (await this.deps.read()).find((m) => m.sender === "me" && m.wireId === wireId);
@@ -12258,7 +12640,8 @@ var EditQueue = class {
12258
12640
  e: edit.seq,
12259
12641
  ts: edit.at,
12260
12642
  m: message.text,
12261
- ...message.preview && { pv: message.preview }
12643
+ ...message.preview && { pv: message.preview },
12644
+ ...message.card && { sc: message.card }
12262
12645
  }, message)) continue;
12263
12646
  this.sent.set(message.id, {
12264
12647
  seq: edit.seq,
@@ -12280,6 +12663,54 @@ var EditQueue = class {
12280
12663
  }, Math.max(0, at - this.now()));
12281
12664
  }
12282
12665
  };
12666
+ /** A card's updates from someone else are applied (stored, shown) at most once per this long, per message. */
12667
+ var CARD_APPLY_GAP_MS = 1e3;
12668
+ /**
12669
+ * Received status card updates, paced per message (WISP 4xx · Status Cards): the first applies at once, the rest within
12670
+ * `CARD_APPLY_GAP_MS` wait, and only the highest edit number of them applies when the time is up. A sender that
12671
+ * ignores its own pace costs this side one write and one render a second per card, whatever it sends.
12672
+ */
12673
+ var CardEditPacer = class {
12674
+ now;
12675
+ gapMs;
12676
+ slots = /* @__PURE__ */ new Map();
12677
+ constructor(now = Date.now, gapMs = CARD_APPLY_GAP_MS) {
12678
+ this.now = now;
12679
+ this.gapMs = gapMs;
12680
+ }
12681
+ /** Applies edit `e` of the message under `key` now, or later with whatever higher edit comes meanwhile. */
12682
+ async take(key, e, apply) {
12683
+ const slot = this.slots.get(key) ?? { last: 0 };
12684
+ this.slots.set(key, slot);
12685
+ const due = slot.last + this.gapMs - this.now();
12686
+ if (due <= 0 && !slot.timer) {
12687
+ slot.last = this.now();
12688
+ await apply();
12689
+ return;
12690
+ }
12691
+ if (slot.e !== void 0 && slot.e >= e) return;
12692
+ slot.e = e;
12693
+ slot.apply = apply;
12694
+ slot.timer ??= setTimeout(() => {
12695
+ const run = slot.apply;
12696
+ slot.timer = void 0;
12697
+ slot.apply = void 0;
12698
+ slot.e = void 0;
12699
+ slot.last = this.now();
12700
+ run?.().catch(() => {});
12701
+ }, Math.max(0, due));
12702
+ if (this.slots.size > 512) {
12703
+ for (const [k, s] of this.slots) if (!s.timer) {
12704
+ this.slots.delete(k);
12705
+ break;
12706
+ }
12707
+ }
12708
+ }
12709
+ stop() {
12710
+ for (const slot of this.slots.values()) clearTimeout(slot.timer);
12711
+ this.slots.clear();
12712
+ }
12713
+ };
12283
12714
  /**
12284
12715
  * The contact's edits of messages not here yet (an edit that overtook its message on another path), per chat: kept
12285
12716
  * `EDIT_BUFFER_MS` at most, the highest number per message, `EDIT_BUFFER_MAX` messages at most.
@@ -12322,13 +12753,6 @@ var UNFINISHED = /* @__PURE__ */ new Set([
12322
12753
  "submitted",
12323
12754
  "unknown"
12324
12755
  ]);
12325
- var sats$1 = (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`;
12326
- var known = (amount, network) => ({
12327
- empty: amount <= 0,
12328
- text: sats$1(Math.max(0, amount), network)
12329
- });
12330
- /** A self-custodial source keeps its recovery phrase sealed with it (Breez, a BDK wallet): the money is on this device. */
12331
- var phraseHeld = (secrets) => !!secrets?.includes("mnemonic");
12332
12756
  var ITEM = {
12333
12757
  request: (a) => `A request for ${a} in a chat, still open`,
12334
12758
  invoice: (a) => `An invoice for ${a}, not paid yet`,
@@ -12336,12 +12760,25 @@ var ITEM = {
12336
12760
  unclaimed: (a) => `${a} the mint says it issued for an invoice, never received here`,
12337
12761
  sent: (a) => `${a} in ecash you sent, not taken yet`
12338
12762
  };
12339
- function items(type, network, view, card) {
12340
- const amount = (n) => type === "usdt" ? `${formatPaymentAmount(String(n), view?.usdt?.decimals ?? 6)} ${network === "testnet" ? "TEST-USDT" : "USDT"}` : sats$1(n, network);
12763
+ var ENGLISH_REMOVAL = {
12764
+ sats: (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`,
12765
+ token: (units, decimals, network) => `${formatPaymentAmount(units, decimals)} ${network === "testnet" ? "TEST-USDT" : "USDT"}`,
12766
+ gas: (wei, network) => `${formatPaymentAmount(wei, 18)} ${network === "testnet" ? "test ETH" : "ETH"}`,
12767
+ item: (kind, amount) => ITEM[kind](amount),
12768
+ and: (first, second) => `${first} and ${second}`
12769
+ };
12770
+ var knownIn = (amount, network, words) => ({
12771
+ empty: amount <= 0,
12772
+ text: words.sats(Math.max(0, amount), network)
12773
+ });
12774
+ /** A self-custodial source keeps its recovery phrase sealed with it (Breez, a BDK wallet): the money is on this device. */
12775
+ var phraseHeld = (secrets) => !!secrets?.includes("mnemonic");
12776
+ function items(type, network, view, words, card) {
12777
+ const amount = (n) => type === "usdt" ? words.token(String(n), view?.usdt?.decimals ?? 6, network) : words.sats(n, network);
12341
12778
  const mine = (view?.awaiting ?? []).filter((a) => a.type === type && (card === void 0 || a.card === void 0 || a.card === card));
12342
12779
  const item = (a) => ({
12343
12780
  kind: a.kind,
12344
- text: ITEM[a.kind](amount(a.amount)),
12781
+ text: words.item(a.kind, amount(a.amount)),
12345
12782
  amount: amount(a.amount),
12346
12783
  ...a.paymentId ? { paymentId: a.paymentId } : {}
12347
12784
  });
@@ -12355,12 +12792,13 @@ function items(type, network, view, card) {
12355
12792
  * What removing the `type` wallet of `network` takes away. `intents`: the profile's payments (the wallet view's
12356
12793
  * `intents`). `card`: the Lightning card (absent: the network's default for receiving).
12357
12794
  */
12358
- function walletRemoval(type, network, view, intents = [], card) {
12795
+ function walletRemoval(type, network, view, intents = [], card, words = ENGLISH_REMOVAL) {
12796
+ const known = (amount) => knownIn(amount, network, words);
12359
12797
  const lnCard = type === "lightning" ? view?.lightnings?.find((c) => card === void 0 ? c.receive : c.card === card) : void 0;
12360
12798
  const base = {
12361
12799
  type,
12362
12800
  network,
12363
- ...items(type, network, view, lnCard?.card ?? card),
12801
+ ...items(type, network, view, words, lnCard?.card ?? card),
12364
12802
  ...lnCard ? { card: lnCard.card } : card !== void 0 ? { card } : {}
12365
12803
  };
12366
12804
  const pending = intents.filter((i) => i.method === type && UNFINISHED.has(i.state) && walletNetworkOf(i.network) === network).length;
@@ -12368,7 +12806,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12368
12806
  case "cashu": return {
12369
12807
  ...base,
12370
12808
  custody: "device",
12371
- held: known(view?.balance ?? 0, network),
12809
+ held: known(view?.balance ?? 0),
12372
12810
  backup: "tokens",
12373
12811
  pending
12374
12812
  };
@@ -12378,7 +12816,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12378
12816
  if (!ln?.providerId || ln.providerId === "cashu-mint") return {
12379
12817
  ...base,
12380
12818
  custody: "elsewhere",
12381
- held: known(0, network),
12819
+ held: known(0),
12382
12820
  backup: "none",
12383
12821
  pending: 0,
12384
12822
  ...others ? {} : { comesWith: "cashu" },
@@ -12389,7 +12827,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12389
12827
  return {
12390
12828
  ...base,
12391
12829
  custody: device ? "device" : "elsewhere",
12392
- held: ln.status === "ready" && ln.balance !== void 0 ? known(ln.balance, network) : device ? "unknown" : known(0, network),
12830
+ held: ln.status === "ready" && ln.balance !== void 0 ? known(ln.balance) : device ? "unknown" : known(0),
12393
12831
  backup: "none",
12394
12832
  pending
12395
12833
  };
@@ -12400,7 +12838,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12400
12838
  return {
12401
12839
  ...base,
12402
12840
  custody: device ? "device" : "elsewhere",
12403
- held: bt?.status === "ready" ? known(total, network) : device ? "unknown" : known(0, network),
12841
+ held: bt?.status === "ready" ? known(total) : device ? "unknown" : known(0),
12404
12842
  backup: "none",
12405
12843
  pending
12406
12844
  };
@@ -12410,7 +12848,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12410
12848
  return {
12411
12849
  ...base,
12412
12850
  custody: "device",
12413
- held: open ? known(ark.balance + (ark.recoverable ?? 0) + (ark.incoming ?? 0), network) : "unknown",
12851
+ held: open ? known(ark.balance + (ark.recoverable ?? 0) + (ark.sweeping ?? 0) + (ark.small ?? 0) + (ark.incoming ?? 0)) : "unknown",
12414
12852
  backup: "phrase",
12415
12853
  pending
12416
12854
  };
@@ -12420,7 +12858,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12420
12858
  return {
12421
12859
  ...base,
12422
12860
  custody: "device",
12423
- held: open ? known(bark.balance, network) : "unknown",
12861
+ held: open ? known(bark.balance) : "unknown",
12424
12862
  backup: "phrase",
12425
12863
  pending
12426
12864
  };
@@ -12430,7 +12868,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12430
12868
  return {
12431
12869
  ...base,
12432
12870
  custody: "device",
12433
- held: open ? known(spark.balance, network) : "unknown",
12871
+ held: open ? known(spark.balance) : "unknown",
12434
12872
  backup: "phrase",
12435
12873
  pending
12436
12874
  };
@@ -12441,7 +12879,7 @@ function walletRemoval(type, network, view, intents = [], card) {
12441
12879
  return {
12442
12880
  ...base,
12443
12881
  custody: "device",
12444
- held: open ? known(fm?.balance ?? 0, network) : "unknown",
12882
+ held: open ? known(fm?.balance ?? 0) : "unknown",
12445
12883
  backup: "phrase",
12446
12884
  pending
12447
12885
  };
@@ -12456,14 +12894,14 @@ function walletRemoval(type, network, view, intents = [], card) {
12456
12894
  pending
12457
12895
  };
12458
12896
  const token = BigInt(usdt.balance || "0"), gas = BigInt(usdt.gasBalance || "0");
12459
- const test = network === "testnet";
12460
- const parts = [...token > 0n || gas === 0n ? [`${formatPaymentAmount(usdt.balance || "0", usdt.decimals ?? 6)} ${test ? "TEST-USDT" : "USDT"}`] : [], ...gas > 0n ? [`${formatPaymentAmount(usdt.gasBalance, 18)} ${test ? "test ETH" : "ETH"}`] : []];
12897
+ const held = token > 0n || gas === 0n ? words.token(usdt.balance || "0", usdt.decimals ?? 6, network) : void 0;
12898
+ const fuel = gas > 0n ? words.gas(usdt.gasBalance, network) : void 0;
12461
12899
  return {
12462
12900
  ...base,
12463
12901
  custody: "device",
12464
12902
  held: {
12465
12903
  empty: token === 0n && gas === 0n,
12466
- text: parts.join(" and ")
12904
+ text: held && fuel ? words.and(held, fuel) : held ?? fuel
12467
12905
  },
12468
12906
  backup: "phrase",
12469
12907
  pending
@@ -12565,9 +13003,13 @@ function walletAwaiting({ network, mints, quotes, lightningOps, lightningSource,
12565
13003
  ...type === "lightning" ? onCard(cardOf.get(p.id)) : {}
12566
13004
  });
12567
13005
  }
13006
+ const paid = payments.filter((p) => p.kind === "request" && p.direction === "out" && p.state === "settled");
13007
+ const paidIds = new Set(paid.map((p) => p.id));
13008
+ const paidInvoices = new Set(paid.filter((p) => p.invoice).map((p) => invoiceKey(p.invoice)));
13009
+ const answered = (x, owner) => owner !== void 0 && paidIds.has(owner) || paidInvoices.has(invoiceKey(x.invoice));
12568
13010
  for (const q of openQuotes) {
12569
13011
  const owner = ownerOf(q);
12570
- if (!owner || !requests.has(owner)) out.push({
13012
+ if ((!owner || !requests.has(owner)) && !answered(q, owner)) out.push({
12571
13013
  type: "cashu",
12572
13014
  kind: "invoice",
12573
13015
  amount: q.amount,
@@ -12576,7 +13018,7 @@ function walletAwaiting({ network, mints, quotes, lightningOps, lightningSource,
12576
13018
  }
12577
13019
  for (const op of openOps) {
12578
13020
  const owner = ownerOf(op);
12579
- if (!owner || !requests.has(owner)) out.push({
13021
+ if ((!owner || !requests.has(owner)) && !answered(op, owner)) out.push({
12580
13022
  type: "lightning",
12581
13023
  kind: "invoice",
12582
13024
  amount: op.amount,
@@ -12605,6 +13047,23 @@ function walletAwaiting({ network, mints, quotes, lightningOps, lightningSource,
12605
13047
  }
12606
13048
  return out;
12607
13049
  }
13050
+ /**
13051
+ * What one Cashu mint still waits for: its invoices not paid yet, paid ones whose ecash is not claimed, and requests
13052
+ * of ours whose invoice is one of them. A request other mints or rails can still be paid through is not counted, nor
13053
+ * ecash sent from it (adding the mint again takes that back).
13054
+ */
13055
+ function mintAwaiting(mint, sources) {
13056
+ const key = (invoice) => invoice.trim().toLowerCase();
13057
+ const invoices = new Set(sources.quotes.filter((q) => q.mint === mint).map((q) => key(q.invoice)));
13058
+ const invoiceOf = new Map(sources.payments.map((p) => [p.id, p.invoice]));
13059
+ const ours = (a) => a.kind !== "request" || !!a.paymentId && invoices.has(key(invoiceOf.get(a.paymentId) ?? ""));
13060
+ return walletAwaiting({
13061
+ ...sources,
13062
+ mints: [mint],
13063
+ lightningOps: [],
13064
+ lightningSource: "none"
13065
+ }).filter((a) => a.type === "cashu" && a.kind !== "sent" && ours(a));
13066
+ }
12608
13067
  //#endregion
12609
13068
  //#region ../browser/src/engine/paymentAdapters/testCoins.ts
12610
13069
  /**
@@ -12940,7 +13399,16 @@ function note(message, by, emoji, at) {
12940
13399
  by,
12941
13400
  emoji,
12942
13401
  snippet: replySnippet(message.text),
12943
- mine: message.sender === "me"
13402
+ mine: message.sender === "me",
13403
+ message: message.id
13404
+ };
13405
+ }
13406
+ /** The note again after its message changed (an edit): its line is the message's text now. Undefined when nothing changed. */
13407
+ function noteAfterChange(current, message) {
13408
+ const snippet = replySnippet(message.text);
13409
+ return snippet === current.snippet ? void 0 : {
13410
+ ...current,
13411
+ snippet
12944
13412
  };
12945
13413
  }
12946
13414
  /**
@@ -13230,6 +13698,12 @@ var BEACON_RETRY_MS = 5e3;
13230
13698
  /** An admission's links keep looking fast (see `keepLooking`), renewed this often, an edge for this long. */
13231
13699
  var REARM_MS = 2e4;
13232
13700
  var AWAIT_EDGE_MS = 12e4;
13701
+ /**
13702
+ * After a restart, the edges of the last run stay open this long, whatever the topology picks at first: the members
13703
+ * and hubs at their other ends watch for this app to come back (a member keeps a hub that went away `hubWaitMs`, a
13704
+ * hub its members a minute, another hub three), and the app dials those that were live once at start (`resume`).
13705
+ */
13706
+ var RESTORED_EDGE_MS = 6e4;
13233
13707
  /** After letting someone in, the door keeps its reads at the admitting pace this long. */
13234
13708
  var ADMITTED_QUIET_MS = 3e4;
13235
13709
  var MAX_PENDING_ENTRIES$1 = 8;
@@ -13270,6 +13744,8 @@ var Communities = class {
13270
13744
  stored = /* @__PURE__ */ new Map();
13271
13745
  live = /* @__PURE__ */ new Map();
13272
13746
  lastMessageAt = /* @__PURE__ */ new Map();
13747
+ /** The latest message from another member, per group: what makes it unread in the list. */
13748
+ lastPeerMessageAt = /* @__PURE__ */ new Map();
13273
13749
  lastMentionAt = /* @__PURE__ */ new Map();
13274
13750
  refused = /* @__PURE__ */ new Map();
13275
13751
  lastKnock = /* @__PURE__ */ new Map();
@@ -13317,11 +13793,15 @@ var Communities = class {
13317
13793
  for (const group of groups) {
13318
13794
  this.stored.set(group.id, group);
13319
13795
  if (group.community) this.attach(group.community);
13320
- const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)), last = history[history.length - 1];
13796
+ const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
13797
+ const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
13321
13798
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
13799
+ if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
13322
13800
  const mention = [...history].reverse().find((m) => m.mentioned);
13323
13801
  if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
13324
13802
  for (const [, linkId] of this.host.entries(group.id)) if (group.joining?.linkId !== linkId) await this.host.closeEdge(linkId);
13803
+ const live = this.live.get(group.id);
13804
+ if (live) for (const key of this.host.edges(group.id).keys()) live.restored.set(key, 0);
13325
13805
  }
13326
13806
  }
13327
13807
  /** A community as kept here, to change in place and save (`Groups.setPin`). */
@@ -13336,6 +13816,7 @@ var Communities = class {
13336
13816
  profile: "community",
13337
13817
  createdAt: group.createdAt,
13338
13818
  lastMessageAt: this.lastMessageAt.get(group.id) ?? 0,
13819
+ ...peerMessageAt(this.lastPeerMessageAt, group.id),
13339
13820
  ...mentionAt(this.lastMentionAt, group.id),
13340
13821
  invited: [],
13341
13822
  memberLinks: {}
@@ -13454,10 +13935,10 @@ var Communities = class {
13454
13935
  traceJoin(link.g, "join.start");
13455
13936
  this.knock(group, since).catch(() => {});
13456
13937
  }
13457
- async send(groupId, text, mentions = [], reply, forwarded) {
13938
+ async send(groupId, text, mentions = [], reply, forwarded, card) {
13458
13939
  const live = this.live.get(groupId);
13459
13940
  if (!live) return { error: "You are not in this group yet" };
13460
- const result = await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded);
13941
+ const result = card ? await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded, card) : await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded);
13461
13942
  return "error" in result ? { error: result.error } : {
13462
13943
  error: null,
13463
13944
  messageId: result.id
@@ -13479,7 +13960,8 @@ var Communities = class {
13479
13960
  v: edit.e,
13480
13961
  ts: edit.ts,
13481
13962
  text: edit.m,
13482
- mentions: edit.k
13963
+ mentions: edit.k,
13964
+ ...edit.sc && { card: edit.sc }
13483
13965
  }, this.host.myNick?.());
13484
13966
  if ("error" in result) return result.error;
13485
13967
  const key = editKey(edit.id, edit.e);
@@ -13578,9 +14060,11 @@ var Communities = class {
13578
14060
  this.live.delete(groupId);
13579
14061
  this.stored.delete(groupId);
13580
14062
  this.lastMessageAt.delete(groupId);
14063
+ this.lastPeerMessageAt.delete(groupId);
13581
14064
  this.lastMentionAt.delete(groupId);
13582
14065
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
13583
14066
  await this.store.deleteGroup(groupId);
14067
+ this.host.historyGone?.(groupId);
13584
14068
  this.host.emit();
13585
14069
  }
13586
14070
  require(groupId) {
@@ -13644,7 +14128,7 @@ var Communities = class {
13644
14128
  live.hubSince = now;
13645
14129
  live.emptySince = now;
13646
14130
  live.knocksScanned = false;
13647
- await this.publishBeacon(groupId, live, now, true);
14131
+ await this.publishBeacon(groupId, live, now, true).catch(() => {});
13648
14132
  traceJoin(groupId, "hub.elected");
13649
14133
  }
13650
14134
  }
@@ -13658,7 +14142,7 @@ var Communities = class {
13658
14142
  live.hub = false;
13659
14143
  await this.closeEntries(groupId);
13660
14144
  await this.publishBeacon(groupId, live, now, false);
13661
- } else if (now - live.lastBeaconTry < BEACON_RETRY_MS) {} else if (now - live.lastBeaconWrite >= COMMUNITY_TOPOLOGY.beaconEveryMs || !live.beacon.some((h) => h.key === me) || now - live.lastBeaconWrite >= 5e3 && Math.abs(this.beaconLoad(groupId, live, now) - (live.beacon.find((h) => h.key === me)?.load ?? load)) >= 8) await this.publishBeacon(groupId, live, now, true);
14145
+ } else if (now - live.lastBeaconTry < BEACON_RETRY_MS) {} else if (now - live.lastBeaconWrite >= COMMUNITY_TOPOLOGY.beaconEveryMs || !live.beacon.some((h) => h.key === me) || now - live.lastBeaconWrite >= 5e3 && Math.abs(this.beaconLoad(groupId, live, now) - (live.beacon.find((h) => h.key === me)?.load ?? load)) >= 8) await this.publishBeacon(groupId, live, now, true).catch(() => {});
13662
14146
  }
13663
14147
  if (live.hub) {
13664
14148
  const busy = this.admitting(groupId, live, now);
@@ -13680,16 +14164,13 @@ var Communities = class {
13680
14164
  live.hubsUp.add(key);
13681
14165
  continue;
13682
14166
  }
13683
- if (live.hubsUp.delete(key)) {
13684
- live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
13685
- live.hubWaits.delete(key);
13686
- continue;
13687
- }
13688
14167
  const since = live.hubWaits.get(key) ?? now;
13689
14168
  live.hubWaits.set(key, since);
13690
- if (now - since > this.timings.hubWaitMs) {
14169
+ const back = live.hubsUp.has(key) && !!id && !!this.host.linkBack?.(id);
14170
+ if (now - since > (back ? 3 : 1) * this.timings.hubWaitMs) {
13691
14171
  live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
13692
14172
  live.hubWaits.delete(key);
14173
+ live.hubsUp.delete(key);
13693
14174
  }
13694
14175
  }
13695
14176
  let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
@@ -13877,6 +14358,11 @@ var Communities = class {
13877
14358
  } else for (const key of live.myHubs) wanted.add(key);
13878
14359
  for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key))) wanted.add(key);
13879
14360
  }
14361
+ for (const [key, since] of live.restored) {
14362
+ if (!since) live.restored.set(key, now);
14363
+ if (!existing.has(key) || now - (since || now) >= RESTORED_EDGE_MS || s.wasRemoved(key)) live.restored.delete(key);
14364
+ else wanted.add(key);
14365
+ }
13880
14366
  for (const [key, id] of existing) if (!wanted.has(key)) await this.host.closeEdge(id);
13881
14367
  for (const key of wanted) if (!existing.has(key) && key !== s.myKey) {
13882
14368
  const expect = live.expect.delete(key);
@@ -13925,7 +14411,7 @@ var Communities = class {
13925
14411
  g: groupId,
13926
14412
  host: s.entryKey
13927
14413
  };
13928
- const hubs = doorHubs(live.beacon, now);
14414
+ const hubs = doorHubs(live.beacon, now, s.myKey);
13929
14415
  if (!hubs.includes(s.myKey)) return;
13930
14416
  const door = [...hubs].sort()[0], doorSig = [...hubs].sort().join(",");
13931
14417
  if (live.doors !== doorSig) {
@@ -13963,7 +14449,7 @@ var Communities = class {
13963
14449
  });
13964
14450
  if (live.knocksSeen.get(key).changed && now - ts > 15e3) continue;
13965
14451
  const waited = now - first, order = [door, ...rankHubs(key, hubs.filter((k) => k !== door))];
13966
- const turn = Math.floor(waited / KNOCK_SLOT_MS) % order.length, early = waited % KNOCK_SLOT_MS < KNOCK_SLOT_OPEN_MS;
14452
+ const turn = Math.floor(waited / KNOCK_SLOT_MS) % order.length, early = order.length === 1 || waited % KNOCK_SLOT_MS < KNOCK_SLOT_OPEN_MS;
13967
14453
  if (order[turn] !== s.myKey || !early || waited >= KNOCK_SLOT_MS && !stillKnocking) continue;
13968
14454
  if (live.pendingEntries.size >= MAX_PENDING_ENTRIES$1) break;
13969
14455
  if (live.members.size >= this.capacity(groupId, live, now)) break;
@@ -14000,7 +14486,7 @@ var Communities = class {
14000
14486
  const joining = group.joining;
14001
14487
  const first = !this.lastKnock.has(group.id);
14002
14488
  this.lastKnock.set(group.id, now);
14003
- if (!first && now - joining.since < this.timings.patienceMs) this.host.expectPeer?.(joining.linkId);
14489
+ if (first || now - joining.since < this.timings.patienceMs) this.host.expectPeer?.(joining.linkId);
14004
14490
  const me = identityFromSeedB64(joining.seedB64).pubKeyZ32, link = {
14005
14491
  g: group.id,
14006
14492
  host: joining.host
@@ -14241,8 +14727,7 @@ var Communities = class {
14241
14727
  message: async (m) => {
14242
14728
  const timestamp = receivedTimestamp(m.timestamp);
14243
14729
  const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
14244
- if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, timestamp));
14245
- await this.host.storeMessage({
14730
+ const stored = await this.host.storeMessage({
14246
14731
  linkId: MESSAGE_LINK$1(id),
14247
14732
  id: m.id,
14248
14733
  text: m.text,
@@ -14252,9 +14737,13 @@ var Communities = class {
14252
14737
  via: "datalink",
14253
14738
  ...mentionFields(m.mentions, mentioned),
14254
14739
  ...m.reply && { replyTo: groupReply(m.reply, session.myKey) },
14255
- ...m.forwarded && { forwarded: m.forwarded }
14740
+ ...m.forwarded && { forwarded: m.forwarded },
14741
+ ...m.card && { card: m.card }
14256
14742
  });
14257
14743
  this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, timestamp));
14744
+ const came = cameAt(timestamp, stored, this.now());
14745
+ if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
14746
+ if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
14258
14747
  },
14259
14748
  app: (m) => this.deliver(id, () => this.host.communityApp?.(id, m.sender, m.frame)),
14260
14749
  pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
@@ -14296,6 +14785,7 @@ var Communities = class {
14296
14785
  hubsUp: /* @__PURE__ */ new Set(),
14297
14786
  lingering: /* @__PURE__ */ new Map(),
14298
14787
  seenHubs: /* @__PURE__ */ new Map(),
14788
+ restored: /* @__PURE__ */ new Map(),
14299
14789
  knocksScanned: false,
14300
14790
  knockCursor: 0,
14301
14791
  lastShardPoll: 0,
@@ -14952,6 +15442,16 @@ var GroupTypings = class {
14952
15442
  };
14953
15443
  //#endregion
14954
15444
  //#region ../browser/src/engine/groups.ts
15445
+ /** What the engine gives the groups: its links, its storage and its state emitter. */
15446
+ /**
15447
+ * `GroupsHost.linkSeen`: a connection with the other end is under way, or its packet is at most `EXPECT_PEER_MS` old
15448
+ * (its offer follows it). Its presence alone lasts `PRESENCE_WINDOW`, 10 minutes: a hub closes an entry session it gave
15449
+ * up on without a last packet, and a joiner back after a restart took that packet for a member answering, stopped
15450
+ * knocking, and stayed "invited" for 10 minutes (2026-09-30).
15451
+ */
15452
+ function otherEndSeen(presence, dataLink, now = Date.now()) {
15453
+ return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presence.lastPacketAt < 3e4;
15454
+ }
14955
15455
  /** The key an edit of message `id` goes by in `FramesTaken`. */
14956
15456
  var editKey = (id, e) => `${id}#e${e}`;
14957
15457
  /**
@@ -14980,6 +15480,18 @@ var mentionAt = (map, groupId) => {
14980
15480
  const at = map.get(groupId);
14981
15481
  return at ? { lastMentionAt: at } : {};
14982
15482
  };
15483
+ /**
15484
+ * When a member's message counts as having come, for unread and the "@": when it reached me, if that is later than
15485
+ * when it was written (a catch-up from another member, a member back after a while). Written before I last looked at
15486
+ * the group but handed to me since, it is news here; by its own time it sorted among what I had read, and the group
15487
+ * never showed unread. A copy I had already (`stored` false) or one completed in place keeps its own time.
15488
+ */
15489
+ var cameAt = (timestamp, stored, now) => stored === false ? timestamp : Math.max(timestamp, now);
15490
+ /** The view's `lastPeerMessageAt`, when there is one. */
15491
+ var peerMessageAt = (map, groupId) => {
15492
+ const at = map.get(groupId);
15493
+ return at ? { lastPeerMessageAt: at } : {};
15494
+ };
14983
15495
  var ENTRY_TIMINGS = {
14984
15496
  pollMs: 5e3,
14985
15497
  warmPollMs: 2e3,
@@ -15067,6 +15579,8 @@ var Groups = class {
15067
15579
  /** Each group's status and newest commit as its history last said them: a line per change of either, never one again. */
15068
15580
  lastTold = /* @__PURE__ */ new Map();
15069
15581
  lastMessageAt = /* @__PURE__ */ new Map();
15582
+ /** The latest message from another member, per group: what makes it unread in the list. */
15583
+ lastPeerMessageAt = /* @__PURE__ */ new Map();
15070
15584
  /** The latest message that names me, per group (the chat list's "@" while it is unread). */
15071
15585
  lastMentionAt = /* @__PURE__ */ new Map();
15072
15586
  reconciling = Promise.resolve();
@@ -15134,8 +15648,10 @@ var Groups = class {
15134
15648
  for (const group of all.filter((g) => !g.community && !g.joining)) {
15135
15649
  this.stored.set(group.id, group);
15136
15650
  if (group.state) this.attach(group.state);
15137
- const history = await this.store.getMessages(MESSAGE_LINK(group.id)), last = history[history.length - 1];
15651
+ const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
15652
+ const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
15138
15653
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
15654
+ if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
15139
15655
  const mention = [...history].reverse().find((m) => m.mentioned);
15140
15656
  if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
15141
15657
  }
@@ -15177,6 +15693,7 @@ var Groups = class {
15177
15693
  profile: "mesh",
15178
15694
  createdAt: group.createdAt,
15179
15695
  lastMessageAt: this.lastMessageAt.get(group.id) ?? 0,
15696
+ ...peerMessageAt(this.lastPeerMessageAt, group.id),
15180
15697
  ...mentionAt(this.lastMentionAt, group.id),
15181
15698
  invited: [...this.invited.get(group.id) ?? []],
15182
15699
  memberLinks: Object.fromEntries(contacts.map(([key, linkId]) => [linkId, key]))
@@ -15208,9 +15725,11 @@ var Groups = class {
15208
15725
  const hubKeys = new Set(onHubs ? this.hubs.hubs(group.id, session, now) : []), policy = session.hubPolicy;
15209
15726
  if (onHubs && this.hubs.isHub(group.id)) hubKeys.add(session.myKey);
15210
15727
  const typing = this.typings.view(session);
15728
+ const former = Object.entries(nicks).filter(([key]) => !rosterHas(session.roster, key));
15211
15729
  return {
15212
15730
  ...base,
15213
15731
  name: session.name,
15732
+ ...former.length ? { formerNames: Object.fromEntries(former) } : {},
15214
15733
  status: session.status,
15215
15734
  statusReason: session.state.statusReason,
15216
15735
  epoch: session.epoch,
@@ -15292,7 +15811,8 @@ var Groups = class {
15292
15811
  v: edit.e,
15293
15812
  ts: edit.ts,
15294
15813
  text: edit.m,
15295
- mentions: edit.k
15814
+ mentions: edit.k,
15815
+ ...edit.sc && { card: edit.sc }
15296
15816
  }, to);
15297
15817
  return "error" in result ? result.error : null;
15298
15818
  }
@@ -15365,13 +15885,13 @@ var Groups = class {
15365
15885
  }
15366
15886
  /**
15367
15887
  * Sends a text; `messageId` is the id it is kept under here (what history, replies and reactions name). `forwarded`:
15368
- * the hop count of a forwarded text (WISP 9xx § Forwards).
15888
+ * the hop count of a forwarded text (WISP 9xx § Forwards). `card`: a checked status card, the text its fallback.
15369
15889
  */
15370
- async send(groupId, text, mentions = [], reply, forwarded) {
15371
- if (this.isCommunity(groupId)) return this.communities.send(groupId, text, mentions, reply, forwarded);
15890
+ async send(groupId, text, mentions = [], reply, forwarded, card) {
15891
+ if (this.isCommunity(groupId)) return card ? this.communities.send(groupId, text, mentions, reply, forwarded, card) : this.communities.send(groupId, text, mentions, reply, forwarded);
15372
15892
  const session = this.sessions.get(groupId);
15373
15893
  if (!session) return { error: "You are not in this group yet" };
15374
- const result = await session.sendText(text, Date.now(), mentions, reply, forwarded);
15894
+ const result = card ? await session.sendText(text, Date.now(), mentions, reply, forwarded, card) : await session.sendText(text, Date.now(), mentions, reply, forwarded);
15375
15895
  this.typings.say(session, false);
15376
15896
  return "error" in result ? { error: result.error } : {
15377
15897
  error: null,
@@ -15438,9 +15958,11 @@ var Groups = class {
15438
15958
  this.invited.delete(groupId);
15439
15959
  this.pendingEntries.delete(groupId);
15440
15960
  this.lastMessageAt.delete(groupId);
15961
+ this.lastPeerMessageAt.delete(groupId);
15441
15962
  this.lastMentionAt.delete(groupId);
15442
15963
  await this.store.deleteGroup(groupId);
15443
15964
  await this.store.putGroup(group);
15965
+ this.host.historyGone?.(groupId);
15444
15966
  for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
15445
15967
  this.host.emit();
15446
15968
  await session.leave();
@@ -15524,6 +16046,7 @@ var Groups = class {
15524
16046
  this.hereActed.delete(groupId);
15525
16047
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
15526
16048
  await this.store.deleteGroup(groupId);
16049
+ this.host.historyGone?.(groupId);
15527
16050
  this.host.membersChanged?.(groupId);
15528
16051
  this.host.emit();
15529
16052
  }
@@ -16224,7 +16747,6 @@ var Groups = class {
16224
16747
  message: async (m) => {
16225
16748
  const timestamp = receivedTimestamp(m.timestamp);
16226
16749
  const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
16227
- if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, timestamp));
16228
16750
  const message = {
16229
16751
  linkId: MESSAGE_LINK(state.id),
16230
16752
  id: m.id,
@@ -16235,10 +16757,14 @@ var Groups = class {
16235
16757
  via: "datalink",
16236
16758
  ...mentionFields(m.mentions, mentioned),
16237
16759
  ...m.reply && { replyTo: groupReply(m.reply, session.myKey) },
16238
- ...m.forwarded && { forwarded: m.forwarded }
16760
+ ...m.forwarded && { forwarded: m.forwarded },
16761
+ ...m.card && { card: m.card }
16239
16762
  };
16240
- await (m.completes && this.host.completeMessage ? this.host.completeMessage(message) : this.host.storeMessage(message));
16763
+ const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
16241
16764
  this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, timestamp));
16765
+ const came = cameAt(timestamp, stored, this.now());
16766
+ if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
16767
+ if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
16242
16768
  this.typings.messageFrom(state.id, m.sender);
16243
16769
  },
16244
16770
  edit: (e) => this.host.groupEdit?.(state.id, e),
@@ -16352,8 +16878,14 @@ var Groups = class {
16352
16878
  * An edge of a group as the group page shows it (WISP 9xx § Mesh): open means group frames flow, which
16353
16879
  * is what "reachable" means everywhere else in groups; what carries it; when the member was last heard.
16354
16880
  */
16355
- function edgeView(live, now = Date.now()) {
16881
+ function edgeView(live, now = Date.now(), noSlot = false) {
16356
16882
  const open = !!live.link?.groupsSupport;
16883
+ if (!open && noSlot) return {
16884
+ linkId: live.stored.id,
16885
+ state: "waiting",
16886
+ noSlot: true,
16887
+ lastSeenAt: Math.max(live.presence.lastPacketAt, live.lastSyncAt)
16888
+ };
16357
16889
  const error = open ? void 0 : live.pairing?.status === "error" ? live.pairing.error : live.discoveryError;
16358
16890
  const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
16359
16891
  return {
@@ -16686,6 +17218,8 @@ var GroupEdits = class {
16686
17218
  host;
16687
17219
  now;
16688
17220
  buffer;
17221
+ /** Members' status card updates, applied at most once a second per message (WISP 4xx · Status Cards). */
17222
+ cards;
16689
17223
  sendPace = /* @__PURE__ */ new Map();
16690
17224
  receivePace = /* @__PURE__ */ new Map();
16691
17225
  timers = /* @__PURE__ */ new Map();
@@ -16695,12 +17229,14 @@ var GroupEdits = class {
16695
17229
  this.host = host;
16696
17230
  this.now = host.now ?? Date.now;
16697
17231
  this.buffer = new EditBuffer(this.now);
17232
+ this.cards = new CardEditPacer(this.now);
16698
17233
  }
16699
17234
  /**
16700
17235
  * Edits a text of mine in a group: the new text here at once, and to the group as soon as the pace allows. Mentions
16701
- * the message had stay where their words still are; `added` are those picked while editing.
17236
+ * the message had stay where their words still are; `added` are those picked while editing. `card`: the status card
17237
+ * of the new version, already checked (WISP 4xx · Status Cards); a card message then takes `STATUS_CARD_LIMITS.edits`.
16702
17238
  */
16703
- async edit(groupId, messageId, raw, added = []) {
17239
+ async edit(groupId, messageId, raw, added = [], card) {
16704
17240
  const refuse = (error) => ({
16705
17241
  error,
16706
17242
  refused: true
@@ -16716,23 +17252,25 @@ var GroupEdits = class {
16716
17252
  if (isJoinNotice(text)) return refuse("An edit cannot read as a join notice.");
16717
17253
  if (utf8Encode(text).length > 16384) return refuse(`Message exceeds ${GROUP_EDIT_TEXT_BYTES} UTF-8 bytes.`);
16718
17254
  const mentions = carryMentions(message, text, Array.isArray(added) ? added : [], !membership.community && !!membership.admin);
16719
- if (text === message.text && JSON.stringify(mentions) === JSON.stringify(message.mentions ?? [])) return {
17255
+ if (text === message.text && JSON.stringify(mentions) === JSON.stringify(message.mentions ?? []) && JSON.stringify(card ?? null) === JSON.stringify(message.card ?? null)) return {
16720
17256
  error: null,
16721
17257
  messageId
16722
17258
  };
16723
- const seq = (message.edit?.seq ?? 0) + 1;
16724
- if (seq > 100) return refuse(`This message was edited 100 times, the most one takes.`);
17259
+ const seq = (message.edit?.seq ?? 0) + 1, most = card ? STATUS_CARD_LIMITS.edits : 100;
17260
+ if (seq > most) return refuse(`This message was edited ${most} times, the most one takes.`);
16725
17261
  const at = this.now();
16726
17262
  await this.host.patch(chat, messageId, (current) => {
16727
17263
  const next = withEdit(current, {
16728
17264
  seq,
16729
17265
  at,
16730
17266
  text,
17267
+ card,
16731
17268
  pending: true
16732
17269
  });
16733
17270
  return {
16734
17271
  text: next.text,
16735
17272
  edit: next.edit,
17273
+ card: next.card,
16736
17274
  mentions: mentions.length ? mentions : void 0
16737
17275
  };
16738
17276
  });
@@ -16775,7 +17313,8 @@ var GroupEdits = class {
16775
17313
  e: edit.seq,
16776
17314
  ts: edit.at,
16777
17315
  m: message.text,
16778
- ...message.mentions?.length && { k: message.mentions }
17316
+ ...message.mentions?.length && { k: message.mentions },
17317
+ ...message.card && { sc: message.card }
16779
17318
  })) {
16780
17319
  retry = Math.min(retry, RETRY_MS);
16781
17320
  continue;
@@ -16817,6 +17356,12 @@ var GroupEdits = class {
16817
17356
  });
16818
17357
  return "waiting";
16819
17358
  }
17359
+ if (edit.sc && message.member === sender && (message.edit?.seq ?? 0) < edit.e) {
17360
+ await this.cards.take(`${chat}\n${message.id}`, edit.e, async () => {
17361
+ await this.apply(chat, membership.me, sender, message, edit);
17362
+ });
17363
+ return "applied";
17364
+ }
16820
17365
  return this.apply(chat, membership.me, sender, message, edit);
16821
17366
  }
16822
17367
  /** A member's message was stored: an edit that came before it is shown now. */
@@ -16835,11 +17380,13 @@ var GroupEdits = class {
16835
17380
  const next = withEdit(current, {
16836
17381
  seq: edit.e,
16837
17382
  at: receivedTimestamp(edit.ts),
16838
- text: edit.m
17383
+ text: edit.m,
17384
+ card: edit.sc
16839
17385
  });
16840
17386
  return {
16841
17387
  text: next.text,
16842
17388
  edit: next.edit,
17389
+ card: next.card,
16843
17390
  mentions: edit.k?.length ? edit.k : void 0,
16844
17391
  mentioned: mentionsMember(edit.k, me) ? true : void 0
16845
17392
  };
@@ -16855,7 +17402,8 @@ var GroupEdits = class {
16855
17402
  e: m.edit.seq,
16856
17403
  ts: m.edit.at,
16857
17404
  m: m.text,
16858
- ...m.mentions?.length && { k: m.mentions }
17405
+ ...m.mentions?.length && { k: m.mentions },
17406
+ ...m.card && { sc: m.card }
16859
17407
  }, to);
16860
17408
  }
16861
17409
  forget(groupId) {
@@ -16865,6 +17413,7 @@ var GroupEdits = class {
16865
17413
  }
16866
17414
  stop() {
16867
17415
  this.stopped = true;
17416
+ this.cards.stop();
16868
17417
  for (const timer of this.timers.values()) clearTimeout(timer);
16869
17418
  this.timers.clear();
16870
17419
  }
@@ -17645,6 +18194,20 @@ var HoldEngine = class {
17645
18194
  if (entry.state === "held") await this.publishPointer(linkId, true).catch(() => {});
17646
18195
  });
17647
18196
  }
18197
+ /**
18198
+ * "Clear all data": stops, then deletes every item held in storage for every contact, and each chat's manifest. A
18199
+ * contact that still has the pointer finds nothing behind it. Nothing is written back to the chats: they go next.
18200
+ */
18201
+ async withdrawAll() {
18202
+ await this.stop();
18203
+ const storage = this.host.storage();
18204
+ if (!storage) return;
18205
+ const names = this.host.linkIds().flatMap((linkId) => {
18206
+ const hold = this.state(linkId);
18207
+ return [...hold.outbox.map((entry) => entry.name), ...hold.mailbox ? [manifestName(storage.space, hold.mailbox)] : []];
18208
+ });
18209
+ await Promise.allSettled(names.map((name) => storage.store.remove(name)));
18210
+ }
17648
18211
  forgetLink(linkId) {
17649
18212
  this.keyCache.delete(linkId);
17650
18213
  this.lastPoll.delete(linkId);
@@ -18182,6 +18745,47 @@ var TransportLog = class {
18182
18745
  }
18183
18746
  }
18184
18747
  /**
18748
+ * When the chat was last live, as far as the history tells: the latest time a live stretch began, moved or ended.
18749
+ * 0 when it never was.
18750
+ */
18751
+ get lastLiveAt() {
18752
+ for (let i = this.history.length - 1; i >= 0; i--) {
18753
+ const e = this.history[i];
18754
+ if (e.kind === "down" && e.restart) continue;
18755
+ if (e.kind === "live" || e.kind === "switched" || e.kind === "down") return e.at;
18756
+ }
18757
+ return 0;
18758
+ }
18759
+ /**
18760
+ * When the live stretch `liveAtLastRun` names began: a contact's offer from before it set that session up (or an
18761
+ * older one) and is not answered after a restart (`GhostLinkOptions.resumeFloor`). None when the history ends off live.
18762
+ */
18763
+ get liveSinceAtLastRun() {
18764
+ for (let i = this.history.length - 1; i >= 0; i--) {
18765
+ const e = this.history[i];
18766
+ if (e.kind === "live") return e.at;
18767
+ if (e.kind === "down" || e.kind === "dht-only") return void 0;
18768
+ }
18769
+ }
18770
+ /**
18771
+ * This run has lasted past the contact's liveness bound, and the chat was never live in it: the session it was live
18772
+ * in when the app last ran is gone on the contact's side too. The history says so, and the next start no longer
18773
+ * takes the chat for "live at last run" and knocks: before, a contact that never came back was knocked on at every
18774
+ * start, run after run (all ten chats of the #966 repro). Written during the run, not as it ends: a page reload or a
18775
+ * crash records nothing then. This run's own knock already went; only the next start reads it. True when written.
18776
+ */
18777
+ notBackAfterRestart(now) {
18778
+ const from = this.seenLive ? void 0 : this.liveAtLastRun;
18779
+ if (!from) return false;
18780
+ this.event({
18781
+ at: now,
18782
+ kind: "down",
18783
+ from,
18784
+ restart: true
18785
+ });
18786
+ return true;
18787
+ }
18788
+ /**
18185
18789
  * The choice `by` last made, as the rows tell it: the transport of its latest choice (a `chose` row, or the switch
18186
18790
  * that choice became), `automatic` when that was going back to the app's rule or there is none.
18187
18791
  */
@@ -19066,10 +19670,10 @@ function normalizeMintUrl(input) {
19066
19670
  try {
19067
19671
  url = new URL(input.trim());
19068
19672
  } catch {
19069
- throw new Error("That is not a valid mint URL");
19673
+ throw engineError("invalidMintUrl");
19070
19674
  }
19071
19675
  const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
19072
- if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw new Error("Mints must use https");
19676
+ if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw engineError("mintNotHttps");
19073
19677
  return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
19074
19678
  }
19075
19679
  function assertAmount(amount) {
@@ -19100,6 +19704,8 @@ var walletTx = (mint, kind, amount, fee, note) => ({
19100
19704
  fee: Math.max(0, fee),
19101
19705
  note: note?.slice(0, 140)
19102
19706
  });
19707
+ /** What a failed payment left in the wallet, said to the person: all of it, or all but the fee the mint kept. */
19708
+ var backInWallet = (lost) => lost > 0 ? `The sats are back in your wallet, less ${lost} sat${lost === 1 ? "" : "s"} the mint kept as its fee.` : "The sats are back in your wallet.";
19103
19709
  /**
19104
19710
  * How long a mint gets to answer when it is only being asked something (its keys, an invoice). A mint
19105
19711
  * that is down can hold a request for minutes; the next mint should get its turn well before that.
@@ -19122,6 +19728,8 @@ var CashuWallet = class {
19122
19728
  locks = /* @__PURE__ */ new Map();
19123
19729
  quoteTimer = null;
19124
19730
  meltTimer = null;
19731
+ /** Per melt quote that ended unpaid: the sats the mint kept all the same (the fee of the split before the melt). */
19732
+ failedFees = /* @__PURE__ */ new Map();
19125
19733
  /**
19126
19734
  * `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
19127
19735
  * invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
@@ -19162,7 +19770,7 @@ var CashuWallet = class {
19162
19770
  try {
19163
19771
  wallet = await this.wallet(normalized);
19164
19772
  } catch (error) {
19165
- throw Object.assign(/* @__PURE__ */ new Error(`Could not reach ${new URL(normalized).host}. Check the address: it should be a Cashu mint.`), { cause: error });
19773
+ throw Object.assign(engineError("mintUnreachable", { host: new URL(normalized).host }), { cause: error });
19166
19774
  }
19167
19775
  const info = wallet.getMintInfo();
19168
19776
  const name = info.name || new URL(normalized).hostname;
@@ -19396,7 +20004,7 @@ var CashuWallet = class {
19396
20004
  };
19397
20005
  });
19398
20006
  }
19399
- throw new Error(candidates.length === 0 && preferred ? "You share no mint with this contact" : "Not enough sats in your wallet");
20007
+ throw engineError(candidates.length === 0 && preferred ? "noSharedMint" : "notEnoughSats");
19400
20008
  }
19401
20009
  async prepareReviewedCashu(mint, amount) {
19402
20010
  assertAmount(amount);
@@ -19416,7 +20024,7 @@ var CashuWallet = class {
19416
20024
  async executeReviewedCashu(review, prepared) {
19417
20025
  return this.locked(prepared.mint, async () => {
19418
20026
  const preview = deserializeSwapPreview(prepared.swap);
19419
- if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || reviewedCashuFee(preview) !== review.fee) throw new Error("Cashu preview does not match review");
20027
+ if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || reviewedCashuFee(preview) !== review.fee) throw new PaymentPreflightError("Cashu preview does not match review");
19420
20028
  const tx = (await openDb()).transaction(STORES.proofs, "readwrite");
19421
20029
  await new Promise((resolve, reject) => {
19422
20030
  const proofs = tx.objectStore(STORES.proofs);
@@ -19435,7 +20043,7 @@ var CashuWallet = class {
19435
20043
  };
19436
20044
  }
19437
20045
  tx.oncomplete = () => resolve();
19438
- tx.onabort = tx.onerror = () => reject(/* @__PURE__ */ new Error("Prepared Cashu inputs are no longer available"));
20046
+ tx.onabort = tx.onerror = () => reject(new PaymentPreflightError(engineText("reviewedSatsGone")));
19439
20047
  });
19440
20048
  const { keep, send } = await (await this.wallet(prepared.mint)).completeSwap(preview);
19441
20049
  return this.finishReviewedCashu(review, prepared, keep, send);
@@ -19500,6 +20108,42 @@ var CashuWallet = class {
19500
20108
  this.events.onChange();
19501
20109
  return token;
19502
20110
  }
20111
+ /**
20112
+ * Proof from the mint that a reviewed swap never happened: every input it would spend reads UNSPENT (not PENDING,
20113
+ * not SPENT), and the mint signed none of its outputs. Read-only: it asks, and never sends the swap.
20114
+ */
20115
+ async reviewedCashuNeverSwapped(prepared) {
20116
+ const preview = deserializeSwapPreview(prepared.swap);
20117
+ if (!preview.inputs.length) return false;
20118
+ const wallet = await this.wallet(prepared.mint);
20119
+ const states = await wallet.checkProofsStates(preview.inputs.map((p) => ({
20120
+ secret: p.secret,
20121
+ id: p.id
20122
+ })));
20123
+ if (states.length !== preview.inputs.length || !states.every((s) => s.state === "UNSPENT")) return false;
20124
+ const outputs = [...prepared.swap.keepOutputs ?? [], ...prepared.swap.sendOutputs ?? []].map(OutputData.deserialize);
20125
+ const restored = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
20126
+ return restored.signatures.length === 0 && restored.outputs.length === 0;
20127
+ }
20128
+ /** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
20129
+ async releaseReviewedCashu(prepared) {
20130
+ const preview = deserializeSwapPreview(prepared.swap);
20131
+ await this.locked(prepared.mint, async () => {
20132
+ await transact([STORES.proofs], (stores) => {
20133
+ const proofs = stores[STORES.proofs];
20134
+ for (const input of preview.inputs) {
20135
+ const request = proofs.get(input.secret);
20136
+ request.onsuccess = () => {
20137
+ const proof = request.result;
20138
+ if (!proof || !proof.reserved || proof.mint !== prepared.mint || proof.C !== input.C) return;
20139
+ const { reserved: _released, ...free } = proof;
20140
+ proofs.put(free);
20141
+ };
20142
+ }
20143
+ });
20144
+ });
20145
+ this.events.onChange();
20146
+ }
19503
20147
  async reviewedCashuSpent(prepared) {
19504
20148
  const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
19505
20149
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
@@ -19587,7 +20231,7 @@ var CashuWallet = class {
19587
20231
  const amount = quote.amount.toNumber();
19588
20232
  const feeReserve = quote.fee_reserve.toNumber();
19589
20233
  if (await this.balanceAt(mint) < amount + feeReserve) {
19590
- lastError = /* @__PURE__ */ new Error("Not enough sats in your wallet");
20234
+ lastError = engineError("notEnoughSats");
19591
20235
  continue;
19592
20236
  }
19593
20237
  return {
@@ -19609,7 +20253,7 @@ var CashuWallet = class {
19609
20253
  async payQuote(quoteId, mint, note, paymentId) {
19610
20254
  try {
19611
20255
  const outcome = await this.locked(mint, () => this.melt(quoteId, mint, note, paymentId));
19612
- if (outcome === "unpaid") throw new Error("The Lightning payment did not go through. The sats are back in your wallet.");
20256
+ if (outcome === "unpaid") throw new Error(`The Lightning payment did not go through. ${backInWallet(this.takeFailedFee(quoteId))}`);
19613
20257
  if (outcome === "pending") this.pollMelts();
19614
20258
  return outcome === "paid";
19615
20259
  } finally {
@@ -19653,7 +20297,11 @@ var CashuWallet = class {
19653
20297
  } catch (error) {
19654
20298
  if (!isMintOperationError(error) && !(error instanceof MeltChangeError)) return "pending";
19655
20299
  const outcome = await this.settleMelt(melt).catch(() => "pending");
19656
- if (outcome === "unpaid") throw error;
20300
+ if (outcome === "unpaid") {
20301
+ const lost = this.takeFailedFee(melt.quote);
20302
+ if (lost > 0) throw Object.assign(/* @__PURE__ */ new Error(`${error instanceof Error ? error.message : String(error)} ${backInWallet(lost)}`), { cause: error });
20303
+ throw error;
20304
+ }
19657
20305
  return outcome;
19658
20306
  }
19659
20307
  if (result.quote.state === "PAID") {
@@ -19673,6 +20321,7 @@ var CashuWallet = class {
19673
20321
  return current ? this.settleMelt(current) : null;
19674
20322
  });
19675
20323
  if (outcome === "paid" || outcome === "unpaid") {
20324
+ this.takeFailedFee(melt.quote);
19676
20325
  this.events.onMeltResolved(melt, outcome === "paid");
19677
20326
  this.events.onChange();
19678
20327
  }
@@ -19707,16 +20356,29 @@ var CashuWallet = class {
19707
20356
  if (states.some((s) => s.state === "PENDING")) return "pending";
19708
20357
  const spent = new Set(inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
19709
20358
  if (spent.size > 0) console.warn(`[wallet] melt ${melt.quote} is unpaid, yet ${spent.size} of its proofs are spent`);
19710
- await transact([STORES.proofs, STORES.melts], (stores) => {
20359
+ const lost = melt.outlay - inputs.filter((p) => !spent.has(p.secret)).reduce((sum, p) => sum + p.amount, 0);
20360
+ await transact([
20361
+ STORES.proofs,
20362
+ STORES.melts,
20363
+ ...lost > 0 ? [STORES.walletTx] : []
20364
+ ], (stores) => {
19711
20365
  for (const p of inputs) if (spent.has(p.secret)) stores[STORES.proofs].delete(p.secret);
19712
20366
  else stores[STORES.proofs].put({
19713
20367
  ...p,
19714
20368
  reserved: false
19715
20369
  });
19716
20370
  stores[STORES.melts].delete(melt.quote);
20371
+ if (lost > 0) stores[STORES.walletTx].put(walletTx(melt.mint, "fee", 0, lost, melt.note ? `Payment failed: ${melt.note}` : "A Lightning payment failed"));
19717
20372
  });
20373
+ if (lost > 0) this.failedFees.set(melt.quote, lost);
19718
20374
  return "unpaid";
19719
20375
  }
20376
+ /** The fee a failed melt cost, once: for the words that say it failed. */
20377
+ takeFailedFee(quote) {
20378
+ const lost = this.failedFees.get(quote) ?? 0;
20379
+ this.failedFees.delete(quote);
20380
+ return lost;
20381
+ }
19720
20382
  /** The invoice is paid: the reserved proofs are spent, the change is ours, and the payment goes in the history. */
19721
20383
  async finishMelt(melt, change) {
19722
20384
  const tx = walletTx(melt.mint, "lightning-out", melt.amount, melt.outlay - sats(change) - melt.amount, melt.note);
@@ -19833,6 +20495,8 @@ function parseSats(value, asset) {
19833
20495
  /** Paying in ecash failed before any token existed, so nothing reached the contact and Lightning is safe to try. */
19834
20496
  var NoEcashError = class extends Error {};
19835
20497
  var arkSats = (network) => network === "bitcoin" ? "sats" : "test sats";
20498
+ /** The English line kept with a payment message (the CLI and older apps show it): test sats say so. */
20499
+ var satsOn = (network) => network === "testnet" ? "test sats" : "sats";
19836
20500
  var ENDPOINT_OF = {
19837
20501
  arkade: ENDPOINT.arkade,
19838
20502
  bark: ENDPOINT.bark,
@@ -19911,8 +20575,12 @@ var PaymentDesk = class {
19911
20575
  accepts(linkId, method, network) {
19912
20576
  return this.host.acceptsNetwork?.(linkId, method, network) ?? true;
19913
20577
  }
19914
- /** The contact has a wallet of that network for the method, as it said; an older contact says nothing, and may. */
20578
+ /**
20579
+ * The contact has a wallet of that network for the method, as it said on the open session; an older contact says
20580
+ * nothing, and may. With no session open its last word is old (it may have made a wallet since): it may too.
20581
+ */
19915
20582
  meets(link, method, network) {
20583
+ if (!link.isDataLinkOpen) return true;
19916
20584
  const theirs = link.peerPaymentNetworks?.(method);
19917
20585
  return !theirs || theirs.includes(network);
19918
20586
  }
@@ -20237,7 +20905,8 @@ var PaymentDesk = class {
20237
20905
  };
20238
20906
  const ecash = params.rail !== "lightning" && !offHere.cashu && (known ? known.includes("cashu") && link.paymentEnabled("cashu") : link.allowsPayment("cashu"));
20239
20907
  const lightning = params.rail !== "cashu" && !offHere.lightning && (known ? known.includes("lightning") && link.paymentEnabled("lightning") : link.allowsPayment("lightning"));
20240
- if (!ecash && !lightning) throw new Error(params.rail && offHere[params.rail] ? offHere[params.rail] : params.rail ? `${params.rail === "cashu" ? "Cashu" : "Lightning"} is not allowed by both of you here` : held ? "Your contact allowed neither Cashu nor Lightning in this chat" : offHere.cashu && offHere.lightning ? offHere.cashu : "Cashu and Lightning are off in this chat");
20908
+ const theirsOff = !!waiting && !waiting.includes("cashu") && !waiting.includes("lightning") && (link.paymentEnabled("cashu") || link.paymentEnabled("lightning"));
20909
+ if (!ecash && !lightning) throw new Error(params.rail && offHere[params.rail] ? offHere[params.rail] : params.rail ? `${params.rail === "cashu" ? "Cashu" : "Lightning"} is not allowed by both of you here` : held ? "Your contact allowed neither Cashu nor Lightning in this chat" : offHere.cashu && offHere.lightning ? offHere.cashu : theirsOff ? "Your contact took no Cashu or Lightning last time. Try again once the chat is live" : "Cashu and Lightning are off in this chat");
20241
20910
  const quote = lightning ? await this.lightning[network].createInvoice(params.amount, id, params.card) : void 0;
20242
20911
  const own = (await this.wallet.view(network)).mints.map((m) => m.url);
20243
20912
  const mints = ecash ? own.filter((url) => isWorthlessMint(url) === (network === "testnet")) : [];
@@ -20319,7 +20988,7 @@ var PaymentDesk = class {
20319
20988
  await this.host.storeMessage({
20320
20989
  linkId,
20321
20990
  id: `me_${params.timestamp}`,
20322
- text: `⚡ Requested ${params.amount.toLocaleString()} sats from the group`,
20991
+ text: `⚡ Requested ${params.amount.toLocaleString()} ${satsOn(network)} from the group`,
20323
20992
  sender: "me",
20324
20993
  timestamp: params.timestamp,
20325
20994
  via: "datalink",
@@ -20402,7 +21071,7 @@ var PaymentDesk = class {
20402
21071
  const quote = await this.lightning[network].quote(request.invoice, params.card);
20403
21072
  if (quote.amount !== request.amount) throw new Error("The invoice does not match the requested amount");
20404
21073
  const feeLimit = Math.min(Math.max(10, Math.ceil(request.amount * .03)), params.maxFee ?? Infinity);
20405
- if (quote.feeReserve > feeLimit) throw new Error(`The Lightning fee (${quote.feeReserve} sats) is too high`);
21074
+ if (quote.feeReserve > feeLimit) throw engineError("lightningFeeTooHigh", { fee: quote.feeReserve });
20406
21075
  await this.save({
20407
21076
  ...this.current(request),
20408
21077
  lightningPending: true,
@@ -20676,7 +21345,7 @@ var PaymentDesk = class {
20676
21345
  await this.host.storeMessage({
20677
21346
  linkId,
20678
21347
  id: `peer_${request.timestamp}`,
20679
- text: `⚡ Requested ${amount.toLocaleString()} sats`,
21348
+ text: `⚡ Requested ${amount.toLocaleString()} ${satsOn(network)}`,
20680
21349
  sender: "peer",
20681
21350
  timestamp: request.timestamp,
20682
21351
  via: held ? "hold" : "datalink",
@@ -21233,7 +21902,7 @@ var PaymentDesk = class {
21233
21902
  await this.host.storeMessage({
21234
21903
  linkId: review.linkId,
21235
21904
  id: `me_${review.createdAt}`,
21236
- text: `${review.amount} sats via Cashu`,
21905
+ text: `${review.amount} ${arkSats(review.network)} via Cashu`,
21237
21906
  sender: "me",
21238
21907
  timestamp: review.createdAt,
21239
21908
  via: "datalink",
@@ -21961,9 +22630,11 @@ var PaymentDesk = class {
21961
22630
  network: mintNetwork(mint)
21962
22631
  });
21963
22632
  let token;
22633
+ let paidFrom;
21964
22634
  try {
21965
22635
  const created = await this.wallet.createToken(params.amount, params.mints, memo, record, params.network);
21966
22636
  token = created.token;
22637
+ paidFrom = created.mint;
21967
22638
  this.payments.set(id, record(created.token, created.mint));
21968
22639
  } catch (error) {
21969
22640
  throw new NoEcashError(error instanceof Error ? error.message : String(error));
@@ -21972,7 +22643,7 @@ var PaymentDesk = class {
21972
22643
  await this.host.storeMessage({
21973
22644
  linkId: params.linkId,
21974
22645
  id: `me_${params.timestamp}`,
21975
- text: `⚡ ${params.amount.toLocaleString()} sats`,
22646
+ text: `⚡ ${params.amount.toLocaleString()} ${satsOn(mintNetwork(paidFrom))}`,
21976
22647
  sender: "me",
21977
22648
  timestamp: params.timestamp,
21978
22649
  via: "datalink",
@@ -22335,7 +23006,7 @@ function transportCue(before, entries, history) {
22335
23006
  }
22336
23007
  /**
22337
23008
  * The contact came with my invite: the first time the inviter's pairing shows them (their packet seen, or past the
22338
- * wait), after it did not. The same test as the invite card's (src/lib/pairingProgress.ts `contactArrived`).
23009
+ * wait), after it did not. The same test as the invite card's (apps/ui/src/lib/pairingProgress.ts `contactArrived`).
22339
23010
  */
22340
23011
  function knockCue(before, after) {
22341
23012
  const arrived = (p) => !!p && (!!p.peerSeen || [
@@ -22413,12 +23084,123 @@ function pushRelayProblem(url) {
22413
23084
  return "Use an https:// relay address";
22414
23085
  }
22415
23086
  //#endregion
22416
- //#region ../browser/src/platform/irohWeb.ts
23087
+ //#region ../browser/src/engine/walletSetup.ts
22417
23088
  /**
22418
- * Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
22419
- * Desktop's native Iroh, but relay only. A page cannot send UDP, so every
22420
- * packet goes through an Iroh relay over a WebSocket; the relay sees who talks
22421
- * to whom and when, never the frames.
23089
+ * What a new profile gets by itself on Mainnet, in this order: payments over Lightning (Cashu), a dollar token (USDT),
23090
+ * and Bitcoin on-chain. A kind with no Mainnet wallet here yet (on-chain, until a Mainnet source that needs no form
23091
+ * exists) is skipped quietly and stays in the list, never shown as a failure.
23092
+ */
23093
+ var DEFAULT_WALLETS = [
23094
+ "cashu",
23095
+ "usdt",
23096
+ "bitcoin"
23097
+ ];
23098
+ /** The network the first-run setup makes wallets on: real money, to receive right away. */
23099
+ var SETUP_NETWORK = "mainnet";
23100
+ var message = (error) => error instanceof Error ? error.message : String(error);
23101
+ /**
23102
+ * The first-run wallet setup: a new profile gets its default Mainnet wallets in the background, once. What is made is
23103
+ * never made again, so a wallet the person removes stays removed. A kind that could not be made (a server down, no
23104
+ * network) keeps its reason for the Wallet page and is tried again quietly at the next start, or when the person asks.
23105
+ * Creating a wallet moves no money.
23106
+ */
23107
+ var WalletSetup = class {
23108
+ host;
23109
+ running = null;
23110
+ constructor(host) {
23111
+ this.host = host;
23112
+ }
23113
+ /** What the Wallet page shows: absent when there is nothing to say (done, or waiting only on a kind not offered). */
23114
+ view() {
23115
+ const record = this.host.record();
23116
+ const failed = (record?.left ?? []).flatMap((type) => {
23117
+ const reason = record?.failed?.[type];
23118
+ return reason ? [{
23119
+ type,
23120
+ network: SETUP_NETWORK,
23121
+ reason
23122
+ }] : [];
23123
+ });
23124
+ if (!this.running && !failed.length) return void 0;
23125
+ return {
23126
+ running: !!this.running,
23127
+ failed
23128
+ };
23129
+ }
23130
+ /**
23131
+ * At start. `fresh`: a new profile (nothing stored yet, no wallet, no chat). Only then is a setup begun; a profile
23132
+ * with a record goes on with what is left; any other profile (one from before this, or whose wallets were removed)
23133
+ * is left alone.
23134
+ */
23135
+ async begin(fresh) {
23136
+ if (!this.host.record()) {
23137
+ if (!fresh) return;
23138
+ await this.host.save({ left: [...DEFAULT_WALLETS] });
23139
+ }
23140
+ await this.run();
23141
+ }
23142
+ /** Tries what is left (or only `only`), one after the other. A run under way is joined, not doubled. */
23143
+ run(only) {
23144
+ if (this.running) return this.running;
23145
+ this.running = this.work(only).finally(() => {
23146
+ this.running = null;
23147
+ this.host.changed();
23148
+ });
23149
+ this.host.changed();
23150
+ return this.running;
23151
+ }
23152
+ /** A wallet of this kind was made on this network (by the setup or by the person): nothing left to make of it. */
23153
+ async made(type, network) {
23154
+ if (network === "mainnet") await this.drop(type);
23155
+ }
23156
+ /** The person does not want this kind made for them: it is not tried again. */
23157
+ async dismiss(type) {
23158
+ await this.drop(type);
23159
+ this.host.changed();
23160
+ }
23161
+ async work(only) {
23162
+ for (const type of [...this.host.record()?.left ?? []]) {
23163
+ if (only && type !== only) continue;
23164
+ const offer = await this.host.offer(type);
23165
+ if (offer?.exists) {
23166
+ await this.drop(type);
23167
+ continue;
23168
+ }
23169
+ if (!offer?.available || offer.needs) continue;
23170
+ try {
23171
+ await this.host.create(type);
23172
+ await this.drop(type);
23173
+ } catch (error) {
23174
+ const record = this.host.record();
23175
+ if (record?.left.includes(type)) await this.host.save({
23176
+ ...record,
23177
+ failed: {
23178
+ ...record.failed,
23179
+ [type]: message(error)
23180
+ }
23181
+ });
23182
+ }
23183
+ this.host.changed();
23184
+ }
23185
+ }
23186
+ async drop(type) {
23187
+ const record = this.host.record();
23188
+ if (!record?.left.includes(type)) return;
23189
+ const failed = { ...record.failed };
23190
+ delete failed[type];
23191
+ await this.host.save({
23192
+ left: record.left.filter((t) => t !== type),
23193
+ ...Object.keys(failed).length ? { failed } : {}
23194
+ });
23195
+ }
23196
+ };
23197
+ //#endregion
23198
+ //#region ../browser/src/platform/irohWeb.ts
23199
+ /**
23200
+ * Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
23201
+ * Desktop's native Iroh, but relay only. A page cannot send UDP, so every
23202
+ * packet goes through an Iroh relay over a WebSocket; the relay sees who talks
23203
+ * to whom and when, never the frames.
22422
23204
  *
22423
23205
  * The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
22424
23206
  * never on the app's first load.
@@ -22603,6 +23385,24 @@ async function createIrohWebEndpoint(seedB64, options = {}) {
22603
23385
  //#region ../browser/src/engine/node.ts
22604
23386
  /** How long a chat waits before listening again after its HyperDHT relay went away. */
22605
23387
  var RELAY_RETRY_MS = 3e4;
23388
+ /** Native listeners an app may hold per transport (the Desktop's Rust Iroh allows eight, `paired_transport.rs`). */
23389
+ var NATIVE_SLOTS = 8;
23390
+ /** How often a group link that found no free native slot tries again. */
23391
+ var GROUP_NATIVE_RETRY_MS = 15e3;
23392
+ /** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
23393
+ var CLEAR_WITHDRAW_MS = 6e3;
23394
+ /**
23395
+ * How soon the chat on screen tries again for a native slot when every one was busy (each dialling, or carrying a
23396
+ * session): a dial to a contact that is not there ends within its 20 s timeout, and leaves its slot free until the next.
23397
+ */
23398
+ var ACTIVE_SLOT_RETRY_MS = 3e3;
23399
+ /**
23400
+ * How long a chat live when the app last ran may take to be live again before that stretch counts as over
23401
+ * (`TransportLog.notBackAfterRestart`): past the contact's liveness bound (three pings missed), the session it held is
23402
+ * gone too. Shorter, an app started twice in quick succession would not knock the second time on a contact still
23403
+ * holding the session.
23404
+ */
23405
+ var RESUME_SPENT_MS = LIVENESS_PING_MS * 4;
22606
23406
  var DEFAULT_SETTINGS = {
22607
23407
  online: true,
22608
23408
  nick: "",
@@ -22640,15 +23440,23 @@ var SPARE_INVITE_WARM_EVERY_MS = 24e4;
22640
23440
  * it anyway. Three pairings in a minute are the whole of a page's relay budget (2026-09-27).
22641
23441
  */
22642
23442
  var SPARE_INVITE_WARM_AFTER_TAKE_MS = 3e4;
23443
+ /**
23444
+ * At start (and back online), writes that can wait go this long after the chats' and groups' first packets: the spare
23445
+ * invite's warm-up, a chat's first control envelope, its capability record (once the chat's native endpoints are up).
23446
+ * A daemon back with two chats and two groups spent 25 of pkarr.pubky.org's 30 requests a minute on writes in its
23447
+ * first 11 s, and read nothing more for the rest of the minute, the others' answers included (bug hunt r7a, 2026-09-29).
23448
+ */
23449
+ var STARTUP_QUIET_MS = 15e3;
22643
23450
  var SPARE_INVITE_MAX_AGE_MS = 9e5;
22644
23451
  /** How long a removal waits for a wallet's rail to claim what was already paid to it (tests shorten it). */
22645
23452
  var removalTiming = { claimMs: 3e4 };
22646
23453
  /** Why a removal needs the person's confirmation: what it holds, what it still waits for, and what confirming means. */
23454
+ /** `label`: what goes, as a noun ("Testnet Cashu wallet", "Testnet Lightning card “Home”"). */
22647
23455
  function lossRefusal(label, removal) {
22648
23456
  const { held, awaiting } = removal;
22649
- const holds = held === "unknown" ? `Ghostly could not read what the ${label} wallet holds.` : held.empty ? "" : `The ${label} wallet holds ${held.text}.`;
23457
+ const holds = held === "unknown" ? `Ghostly could not read what the ${label} holds.` : held.empty ? "" : `The ${label} holds ${held.text}.`;
22650
23458
  const listed = awaiting.slice(0, 3).map((i) => i.text.charAt(0).toLowerCase() + i.text.slice(1)).join("; ") + (awaiting.length > 3 ? `; and ${awaiting.length - 3} more` : "");
22651
- return `${holds}${awaiting.length ? `${holds ? " It" : `The ${label} wallet`} still waits for money: ${listed}.` : ""} Confirm that ${holds && awaiting.length ? `${held === "unknown" ? "anything in it becomes" : "they become"} unreachable without its backup, and that what is paid to it afterwards is lost,` : holds ? `${held === "unknown" ? "anything in it becomes" : "they become"} unreachable without its backup` : "what is paid to it after it is removed is lost"} to remove it.`;
23459
+ return `${holds}${awaiting.length ? `${holds ? " It" : `The ${label}`} still waits for money: ${listed}.` : ""} Confirm that ${holds && awaiting.length ? `${held === "unknown" ? "anything in it becomes" : "they become"} unreachable without its backup, and that what is paid to it afterwards is lost,` : holds ? `${held === "unknown" ? "anything in it becomes" : "they become"} unreachable without its backup` : "what is paid to it after it is removed is lost"} to remove it.`;
22652
23460
  }
22653
23461
  function newLiveLink(stored, lastMessageAt, files = emptyLinkFiles()) {
22654
23462
  return {
@@ -22719,11 +23527,13 @@ var GhostlyNode = class GhostlyNode {
22719
23527
  activeLinkId = null;
22720
23528
  nativeQueue = Promise.resolve();
22721
23529
  shuttingDown = false;
23530
+ /** `start` has put the kept transfers back (EngineState.transfersRestored). */
23531
+ transfersRestored = false;
22722
23532
  feedbackStartedAt = Date.now();
22723
23533
  feedbackIds = /* @__PURE__ */ new Set();
22724
23534
  walletFeedbackReady = false;
22725
23535
  walletFeedbackIds = /* @__PURE__ */ new Set();
22726
- /** `cue`: a finer sound the page plays instead of the event's own when its category is on (src/lib/cues.ts). */
23536
+ /** `cue`: a finer sound the page plays instead of the event's own when its category is on (apps/ui/src/lib/cues.ts). */
22727
23537
  feedback(type, id, linkId, mention = false, cue) {
22728
23538
  const key = type + ":" + id;
22729
23539
  if (this.feedbackIds.has(key)) return;
@@ -22773,6 +23583,10 @@ var GhostlyNode = class GhostlyNode {
22773
23583
  editQueues = /* @__PURE__ */ new Map();
22774
23584
  /** The contacts' edits of messages not here yet. */
22775
23585
  editBuffer = new EditBuffer();
23586
+ /** Received status card updates, applied at most once a second per message (WISP 4xx · Status Cards). */
23587
+ cardEdits = new CardEditPacer();
23588
+ /** When each message's buttons were last pressed here (`pressButton`). */
23589
+ pressedAt = /* @__PURE__ */ new Map();
22776
23590
  /** At most one wake-up per contact per `WAKE_INTERVAL_MS` (WISP 401 § Wake-up push). */
22777
23591
  wakeLimiter = new WakeLimiter();
22778
23592
  /** Call wake-ups have their own, shorter limit: a call is rarer than a message and cannot wait five minutes. */
@@ -22794,6 +23608,7 @@ var GhostlyNode = class GhostlyNode {
22794
23608
  storeMessage: (message) => this.storeMessage(message),
22795
23609
  transfers: this.transfers,
22796
23610
  changed: (delayMs) => this.emitState(delayMs),
23611
+ flush: () => this.flushState(),
22797
23612
  settled: (linkId, fileId, record, seen) => {
22798
23613
  if (seen && record.direction === "in" && record.state === "done") this.cueFeedback({
22799
23614
  cue: "downloaded",
@@ -22804,6 +23619,8 @@ var GhostlyNode = class GhostlyNode {
22804
23619
  });
22805
23620
  stateTimer = null;
22806
23621
  paymentTimer = null;
23622
+ /** Every minute, the reviews no Approve can send any more are cancelled (dropStaleReviews). */
23623
+ staleReviewTimer;
22807
23624
  /** Group links: admins read knocks, joiners knock (WISP 9xx § Entry link). */
22808
23625
  groupEntryTimer = null;
22809
23626
  walletView = {
@@ -22812,6 +23629,34 @@ var GhostlyNode = class GhostlyNode {
22812
23629
  history: [],
22813
23630
  feesPaid: 0
22814
23631
  };
23632
+ /** A new profile's default Mainnet wallets, made once in the background (`NodeOptions.defaultWallets`). */
23633
+ walletSetup = new WalletSetup({
23634
+ record: () => this.settings.walletSetup,
23635
+ save: async (record) => {
23636
+ this.settings = {
23637
+ ...this.settings,
23638
+ walletSetup: record
23639
+ };
23640
+ await db.putSettings(this.settings);
23641
+ },
23642
+ offer: async (type) => {
23643
+ await this.refreshWallet();
23644
+ return this.walletView.offers?.find((o) => o.type === type && o.network === "mainnet");
23645
+ },
23646
+ create: async (type) => {
23647
+ await this.walletCreate({
23648
+ type,
23649
+ network: SETUP_NETWORK
23650
+ });
23651
+ },
23652
+ changed: () => {
23653
+ this.walletView = {
23654
+ ...this.walletView,
23655
+ setup: this.walletSetup.view()
23656
+ };
23657
+ this.emitState();
23658
+ }
23659
+ });
22815
23660
  /**
22816
23661
  * Every wallet twice, one per network (real money, test coins), both open at once. Nothing is parked: a payment
22817
23662
  * goes through the wallet of its own network, and a wallet of one network never pays for the other.
@@ -22890,7 +23735,10 @@ var GhostlyNode = class GhostlyNode {
22890
23735
  method: "cashu",
22891
23736
  prepare: (target, amount, feeCap) => new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).prepare(target, amount, feeCap),
22892
23737
  execute: (review, prepared, persist) => new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).execute(review, prepared, persist),
22893
- reconcile: (review, prepared) => new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).reconcile(review, prepared)
23738
+ reconcile: (review, prepared, persist) => {
23739
+ if (!persist) throw new Error("A Cashu payment is reconciled with its intent saved");
23740
+ return new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).reconcile(review, prepared, persist);
23741
+ }
22894
23742
  }
22895
23743
  ], (review) => {
22896
23744
  if (review.state === "settled") this.feedback("confirmed", review.id);
@@ -23411,8 +24259,13 @@ var GhostlyNode = class GhostlyNode {
23411
24259
  },
23412
24260
  linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
23413
24261
  myNick: () => this.sharedNick,
23414
- staysOnline: () => this.options.staysOnline ?? this.options.platform === "desktop",
24262
+ staysOnline: () => this.options.staysOnline ?? (this.options.platform === "desktop" && typeof RTCPeerConnection !== "undefined"),
23415
24263
  peerRoom: (groupId) => {
24264
+ if (typeof RTCPeerConnection === "undefined" && Object.keys(this.nativeFactories).length) {
24265
+ let held = 0;
24266
+ for (const live of this.links.values()) if (live.stored.group && live.stored.group !== groupId && live.link) held++;
24267
+ return Math.max(0, 4 - held);
24268
+ }
23416
24269
  const budget = this.options.peerBudget;
23417
24270
  if (budget === void 0) return void 0;
23418
24271
  let held = this.openChatPeers();
@@ -23432,7 +24285,11 @@ var GhostlyNode = class GhostlyNode {
23432
24285
  openEntry: (link, role, seedB64, peer) => this.openEntry(link, role, seedB64, peer),
23433
24286
  linkSeen: (linkId) => {
23434
24287
  const live = this.links.get(linkId);
23435
- return !!live?.presence?.online || !!live?.dataLink && live.dataLink !== "idle";
24288
+ return otherEndSeen(live?.presence, live?.dataLink);
24289
+ },
24290
+ linkBack: (linkId) => {
24291
+ const live = this.links.get(linkId);
24292
+ return !!live?.lastSyncAt && !!live.presence?.online && live.presence.lastPacketAt > live.lastSyncAt;
23436
24293
  },
23437
24294
  publish: (identity, records, background) => this.groupTransport.publish(identity, records, { background }),
23438
24295
  resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
@@ -23449,9 +24306,10 @@ var GhostlyNode = class GhostlyNode {
23449
24306
  else this.closeGroupLink(linkId).catch(() => {});
23450
24307
  },
23451
24308
  edgeNick: (linkId) => this.links.get(linkId)?.presence.nick || void 0,
23452
- storeMessage: (message) => this.storeMessage(message),
24309
+ storeMessage: (message) => this.storeNewMessage(message),
23453
24310
  completeMessage: (message) => this.completeGroupMessage(message),
23454
24311
  membersChanged: (groupId) => this.groupMembersChanged(groupId),
24312
+ historyGone: (groupId) => this.groupHistoryGone(groupId),
23455
24313
  emit: () => this.emitState(),
23456
24314
  communityApp: (groupId, sender, frame) => frame.t === "reaction" ? this.receiveGroupReaction(groupId, sender, frame) : frame.t === "edit" ? this.receiveCommunityEdit(groupId, sender, frame) : frame.t === "pin" ? this.receiveGroupPin(groupId, sender, readPin(frame)) : this.communityPay.receiveApp(groupId, sender, frame),
23457
24315
  groupEdit: async (groupId, { sender, ...edit }) => {
@@ -23590,9 +24448,11 @@ var GhostlyNode = class GhostlyNode {
23590
24448
  * What each network's wallets still wait for (see `walletAwaiting`): open requests and invoices, paid invoices not
23591
24449
  * claimed yet, ecash sent and not taken. Read from the Cashu quotes, the Lightning journals and the payments.
23592
24450
  */
24451
+ /** What each network's wallets still wait for, and each mint on its own (`mints`, only the mints that wait for something). */
23593
24452
  async readAwaiting() {
23594
24453
  const quotes = await this.wallet.quotes(), payments = this.desk.records(), now = Date.now();
23595
24454
  const out = {};
24455
+ const mints = /* @__PURE__ */ new Map();
23596
24456
  for (const network of WALLET_NETWORKS) {
23597
24457
  const lightning = this.lightnings[network];
23598
24458
  out[network] = walletAwaiting({
@@ -23606,8 +24466,27 @@ var GhostlyNode = class GhostlyNode {
23606
24466
  payments,
23607
24467
  now
23608
24468
  });
24469
+ for (const url of this.networkMints(network)) {
24470
+ const waits = mintAwaiting(url, {
24471
+ network,
24472
+ quotes,
24473
+ payments,
24474
+ now
24475
+ });
24476
+ if (waits.length) mints.set(url, waits);
24477
+ }
23609
24478
  }
23610
- return out;
24479
+ return {
24480
+ networks: out,
24481
+ mints
24482
+ };
24483
+ }
24484
+ /** A mint as the wallet page shows it, with what it still waits for when it does (asked about before it is removed). */
24485
+ withWaits(mints, waits) {
24486
+ return mints.map(({ awaiting: _, ...mint }) => waits.has(mint.url) ? {
24487
+ ...mint,
24488
+ awaiting: waits.get(mint.url)
24489
+ } : mint);
23611
24490
  }
23612
24491
  awaitingTimer;
23613
24492
  /** A payment changed (a request made, paid or closed): what the wallets wait for is read again, soon. */
@@ -23619,7 +24498,8 @@ var GhostlyNode = class GhostlyNode {
23619
24498
  if (this.shuttingDown || !networks) return;
23620
24499
  for (const network of WALLET_NETWORKS) networks[network] = {
23621
24500
  ...networks[network],
23622
- awaiting: awaiting[network]
24501
+ mints: this.withWaits(networks[network].mints, awaiting.mints),
24502
+ awaiting: awaiting.networks[network]
23623
24503
  };
23624
24504
  this.walletView = {
23625
24505
  ...this.walletView,
@@ -23637,7 +24517,7 @@ var GhostlyNode = class GhostlyNode {
23637
24517
  everything = view.history;
23638
24518
  const history = view.history.filter((tx) => !tx.mint || mintNetwork(tx.mint) === network);
23639
24519
  networks[network] = {
23640
- mints: view.mints,
24520
+ mints: this.withWaits(view.mints, awaiting.mints),
23641
24521
  balance: view.balance,
23642
24522
  history,
23643
24523
  feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0),
@@ -23649,7 +24529,7 @@ var GhostlyNode = class GhostlyNode {
23649
24529
  lightning: this.lightnings[network].view,
23650
24530
  lightnings: this.lightnings[network].views(),
23651
24531
  bitcoin: this.bitcoins[network].view,
23652
- awaiting: awaiting[network]
24532
+ awaiting: awaiting.networks[network]
23653
24533
  };
23654
24534
  }
23655
24535
  const wallets = walletInstances(networks);
@@ -23658,7 +24538,8 @@ var GhostlyNode = class GhostlyNode {
23658
24538
  networks,
23659
24539
  wallets,
23660
24540
  offers: this.walletOffers(networks, wallets),
23661
- intents: (await intentRepository.list()).map((saved) => saved.review)
24541
+ intents: (await intentRepository.list()).map((saved) => saved.review),
24542
+ setup: this.walletSetup.view()
23662
24543
  };
23663
24544
  this.announcePaymentNetworks(wallets);
23664
24545
  for (const tx of everything) {
@@ -23670,6 +24551,57 @@ var GhostlyNode = class GhostlyNode {
23670
24551
  }
23671
24552
  }
23672
24553
  this.walletFeedbackReady = true;
24554
+ await this.observeBackups();
24555
+ this.emitState();
24556
+ }
24557
+ /** Whether this profile's settings are read: before that, a reminder saved would write the defaults over them. */
24558
+ backupsLoaded = false;
24559
+ /** The backup reminders (shared/backupReminder): what the Mainnet balances change in them, saved when they do. */
24560
+ async observeBackups() {
24561
+ if (this.backupsLoaded) {
24562
+ const view = this.walletView;
24563
+ const { records, changed } = observeBalances(this.settings.backupReminders ?? {}, mainnetBalances(view.wallets ?? [], view.networks?.mainnet), Date.now());
24564
+ if (changed) await this.saveBackups(records);
24565
+ }
24566
+ this.walletView = {
24567
+ ...this.walletView,
24568
+ backupReminders: this.settings.backupReminders ?? {}
24569
+ };
24570
+ }
24571
+ async saveBackups(records) {
24572
+ this.settings = {
24573
+ ...this.settings,
24574
+ backupReminders: records
24575
+ };
24576
+ this.walletView = {
24577
+ ...this.walletView,
24578
+ backupReminders: records
24579
+ };
24580
+ await db.putSettings(this.settings);
24581
+ }
24582
+ /** A wallet's phrase shown or its backup file made (once it answered): a copy of it exists, its reminder is over. */
24583
+ async copied(type, network, work) {
24584
+ const result = await work();
24585
+ const net = this.net(network);
24586
+ if (net === "mainnet" && this.backupsLoaded) {
24587
+ await this.saveBackups(markBackedUp(this.settings.backupReminders ?? {}, {
24588
+ kind: "phrase",
24589
+ id: `${type}:${net}`
24590
+ }, Date.now()));
24591
+ this.emitState();
24592
+ }
24593
+ return result;
24594
+ }
24595
+ /** The backup reminder's "Later" on one Mainnet wallet, or a profile backup just made (see `EngineApi`). */
24596
+ async walletBackupReminder(params) {
24597
+ const records = this.settings.backupReminders ?? {}, now = Date.now();
24598
+ if (params?.event === "profile") await this.saveBackups(markBackedUp(records, { kind: "profile" }, now));
24599
+ else if (params?.event === "later" && typeof params.wallet === "string") {
24600
+ const view = this.walletView;
24601
+ const held = mainnetBalances(view.wallets ?? [], view.networks?.mainnet).find((b) => b.id === params.wallet);
24602
+ if (!held) throw new Error("There is no such Mainnet wallet");
24603
+ await this.saveBackups(putOff(records, held.id, held.balance, now));
24604
+ } else throw new Error("Unknown backup reminder event");
23673
24605
  this.emitState();
23674
24606
  }
23675
24607
  async pollPaymentStatus() {
@@ -23685,14 +24617,17 @@ var GhostlyNode = class GhostlyNode {
23685
24617
  }
23686
24618
  }
23687
24619
  async start() {
24620
+ const stored = await db.getSettings();
24621
+ const fresh = Object.keys(stored).length === 0;
23688
24622
  this.settings = {
23689
24623
  ...DEFAULT_SETTINGS,
23690
- ...await db.getSettings()
24624
+ ...stored
23691
24625
  };
23692
24626
  this.settings.relays = currentRelays(this.settings.relays);
23693
24627
  this.relays?.setRelays(this.settings.relays);
23694
24628
  this.configureDirect();
23695
24629
  this.services = await db.getServices();
24630
+ this.backupsLoaded = true;
23696
24631
  await this.identities.load();
23697
24632
  this.identities.start();
23698
24633
  await this.did.load();
@@ -23714,6 +24649,8 @@ var GhostlyNode = class GhostlyNode {
23714
24649
  await this.desk.start();
23715
24650
  await this.refreshWallet();
23716
24651
  this.wallet.start();
24652
+ this.dropStaleReviews();
24653
+ this.staleReviewTimer ??= setInterval(() => void this.dropStaleReviews(), 6e4);
23717
24654
  this.stopWatchingAdapters ??= onAdaptersChanged(() => {
23718
24655
  if (!this.shuttingDown) for (const network of WALLET_NETWORKS) {
23719
24656
  this.lightnings[network].refreshOffered();
@@ -23724,7 +24661,8 @@ var GhostlyNode = class GhostlyNode {
23724
24661
  for (const stored of await db.getLinks()) {
23725
24662
  const messages = stored.group ? [] : await db.getMessages(stored.id);
23726
24663
  const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
23727
- for (const file of storedFiles) if (file.transfer && !file.wire3) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
24664
+ const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
24665
+ for (const file of storedFiles) if (file.transfer && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
23728
24666
  ...file.transfer,
23729
24667
  state: "failed",
23730
24668
  error: "Transfer interrupted. Retry when connected."
@@ -23737,13 +24675,15 @@ var GhostlyNode = class GhostlyNode {
23737
24675
  if (note) this.reactionNotes.set(stored.id, note);
23738
24676
  if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
23739
24677
  }
24678
+ this.transfersRestored = true;
23740
24679
  await this.groups.load();
23741
24680
  for (const group of this.groups.views()) {
23742
24681
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
23743
24682
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
23744
24683
  }
23745
24684
  this.publicProfiles.start();
23746
- if (this.settings.online) for (const [linkId, messages] of history) {
24685
+ if (this.settings.online) for (const linkId of this.nativeStartOrder([...history.keys()])) {
24686
+ const messages = history.get(linkId);
23747
24687
  if (!this.links.has(linkId)) continue;
23748
24688
  this.startLink(linkId, messages);
23749
24689
  if (!this.links.get(linkId)?.stored.group) this.refreshPublicProfiles({ linkId }).catch(() => {});
@@ -23752,7 +24692,7 @@ var GhostlyNode = class GhostlyNode {
23752
24692
  if (this.settings.online) {
23753
24693
  this.hold.start();
23754
24694
  this.startGroupEntries();
23755
- this.prepareSpare();
24695
+ this.prepareSpare(STARTUP_QUIET_MS);
23756
24696
  }
23757
24697
  this.pollPaymentStatus().catch(() => {});
23758
24698
  Promise.all(WALLET_NETWORKS.map((n) => this.fedimintWallets[n].ensureReady())).then(() => this.desk.resumeFedimint());
@@ -23761,6 +24701,23 @@ var GhostlyNode = class GhostlyNode {
23761
24701
  this.bitcoins[network].ensureReady();
23762
24702
  this.openWallets(network);
23763
24703
  }
24704
+ this.startWalletSetup(fresh && !this.walletView.wallets?.length && this.links.size === 0).catch(() => {});
24705
+ }
24706
+ /** The first-run wallet setup, where the app runs it (`NodeOptions.defaultWallets`): begun once, then what is left. */
24707
+ async startWalletSetup(fresh) {
24708
+ const option = this.options.defaultWallets;
24709
+ if (!(typeof option === "function" ? await option() : option === true) || this.shuttingDown) return;
24710
+ await this.walletSetup.begin(fresh);
24711
+ }
24712
+ /** Wallet page: try again to make a default wallet the first-run setup could not make. */
24713
+ async walletSetupRetry({ type }) {
24714
+ if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
24715
+ await this.walletSetup.run(type);
24716
+ }
24717
+ /** Wallet page: the person does not want this default wallet made for them; it is not tried again. */
24718
+ async walletSetupDismiss({ type }) {
24719
+ if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
24720
+ await this.walletSetup.dismiss(type);
23764
24721
  }
23765
24722
  /** Opens the wallets of a network that exist. None is made here: wallets are made one at a time, with New. */
23766
24723
  openWallets(network) {
@@ -23782,8 +24739,12 @@ var GhostlyNode = class GhostlyNode {
23782
24739
  this.shuttingDown = true;
23783
24740
  this.depart();
23784
24741
  if (this.relayRetry) clearTimeout(this.relayRetry);
24742
+ if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
24743
+ for (const timer of this.resumeTimers.values()) clearTimeout(timer);
24744
+ this.resumeTimers.clear();
23785
24745
  if (this.paymentTimer) clearTimeout(this.paymentTimer);
23786
24746
  clearTimeout(this.awaitingTimer);
24747
+ clearInterval(this.staleReviewTimer);
23787
24748
  if (this.spareTimer) clearTimeout(this.spareTimer);
23788
24749
  for (const timer of this.reactionTimers.values()) clearTimeout(timer);
23789
24750
  this.reactionTimers.clear();
@@ -23810,11 +24771,28 @@ var GhostlyNode = class GhostlyNode {
23810
24771
  await Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop()));
23811
24772
  for (const queue of this.editQueues.values()) queue.stop();
23812
24773
  this.groupEdits.stop();
24774
+ this.cardEdits.stop();
23813
24775
  await Promise.allSettled([...this.links.values()].map(async (live) => {
23814
24776
  await live.link?.stop(true);
23815
24777
  await live.caps?.stop();
23816
24778
  }));
23817
24779
  }
24780
+ /**
24781
+ * "Clear all data" (WISP 04), the peer's part. First what can be taken back from the network, best effort and within
24782
+ * `CLEAR_WITHDRAW_MS` in all: items held in storage for contacts, the DID (a `deactivated` record), the identity
24783
+ * proofs (their revocation records, once more). Then the peer stops, so nothing it holds in memory is written back,
24784
+ * and its database keeps only the wallets. The host starts a new peer afterwards (the page reloads).
24785
+ */
24786
+ async clearProfileData() {
24787
+ const withdraw = Promise.allSettled([
24788
+ this.hold.withdrawAll(),
24789
+ this.did.deactivate(),
24790
+ this.identities.revokeAll()
24791
+ ]);
24792
+ await Promise.race([withdraw, new Promise((resolve) => setTimeout(resolve, CLEAR_WITHDRAW_MS))]);
24793
+ await this.shutdown();
24794
+ await clearProfileStores();
24795
+ }
23818
24796
  getState() {
23819
24797
  const groups = this.groups.views();
23820
24798
  return {
@@ -23826,7 +24804,9 @@ var GhostlyNode = class GhostlyNode {
23826
24804
  defaults: [...DEFAULT_IROH_RELAYS]
23827
24805
  } } : {},
23828
24806
  ...this.transport.configure && { direct: true },
23829
- ...this.transport.discovery && { discovery: this.transport.discovery() }
24807
+ ...this.transport.discovery && { discovery: this.transport.discovery() },
24808
+ ...typeof RTCPeerConnection === "undefined" && { webrtc: false },
24809
+ ...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
23830
24810
  },
23831
24811
  links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
23832
24812
  services: this.services.map((s) => ({
@@ -23834,6 +24814,7 @@ var GhostlyNode = class GhostlyNode {
23834
24814
  requests: this.requestCounts.get(s.id) ?? 0
23835
24815
  })).sort((a, b) => a.createdAt - b.createdAt),
23836
24816
  transfers: Object.fromEntries(this.transfers),
24817
+ transfersRestored: this.transfersRestored,
23837
24818
  wallet: this.walletView,
23838
24819
  payments: this.desk.views(),
23839
24820
  identityProofs: this.identities.views(),
@@ -23883,7 +24864,7 @@ var GhostlyNode = class GhostlyNode {
23883
24864
  }
23884
24865
  /** The edge of a group toward one member, as the group page shows it: what carries it and when the member was last heard. */
23885
24866
  edgeView(groupId, member) {
23886
- for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer === member && !live.stored.groupEntry) return edgeView(live);
24867
+ for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer === member && !live.stored.groupEntry) return edgeView(live, Date.now(), this.groupNativeWaiting.has(live.stored.id));
23887
24868
  }
23888
24869
  getMessages(linkId) {
23889
24870
  return db.getMessages(linkId);
@@ -24266,6 +25247,17 @@ var GhostlyNode = class GhostlyNode {
24266
25247
  if (members && holders.every((key) => members.has(key))) return;
24267
25248
  this.rotateWake().then(() => this.emitState());
24268
25249
  }
25250
+ /**
25251
+ * A group I left or forgot: its history is gone here, and the pages' copy goes too (an empty history, sent in order
25252
+ * with what came before). A page hears only what changes in a history it holds: a community left and joined again
25253
+ * by its link showed its old history under the new messages until a reload (bug hunt r5b, 2026-09-29).
25254
+ */
25255
+ groupHistoryGone(groupId) {
25256
+ const linkId = `group:${groupId}`;
25257
+ this.newestAt.delete(linkId);
25258
+ this.reactionNotes.delete(linkId);
25259
+ this.events.onMessages(linkId, []);
25260
+ }
24269
25261
  async setGroupWakeMuted(groupId, muted) {
24270
25262
  const list = this.settings.wakeMutedGroups ?? [];
24271
25263
  if (list.includes(groupId) === muted) return;
@@ -24502,8 +25494,22 @@ var GhostlyNode = class GhostlyNode {
24502
25494
  label: stored.label
24503
25495
  }));
24504
25496
  }
25497
+ /**
25498
+ * `card`: a bot's status card (WISP 4xx · Status Cards), checked here by the sender's rule; the text is its fallback,
25499
+ * written from the card when none is given. Only the headless runtime and SDKs send one: the app never offers it.
25500
+ */
24505
25501
  async sendMessage(params) {
24506
- const { linkId, text } = params;
25502
+ const { linkId } = params;
25503
+ const card = params.card === void 0 ? void 0 : GhostlyNode.cardToSend(params.card);
25504
+ if (typeof card === "string") return {
25505
+ error: card,
25506
+ refused: true
25507
+ };
25508
+ const text = card && !(typeof params.text === "string" && params.text.trim()) ? statusCardText(card) : params.text;
25509
+ if (typeof text !== "string") return {
25510
+ error: "Nothing to send",
25511
+ refused: true
25512
+ };
24507
25513
  const live = this.links.get(linkId);
24508
25514
  const trimmed = text.trim();
24509
25515
  const bytes = new TextEncoder().encode(trimmed).length;
@@ -24516,13 +25522,26 @@ var GhostlyNode = class GhostlyNode {
24516
25522
  const timestamp = params.timestamp ?? Date.now();
24517
25523
  if (live.stored.profile) live.link.setTyping(false);
24518
25524
  if (live.stored.profile) {
24519
- const reply = params.replyTo === void 0 ? void 0 : await this.replyFor(linkId, params.replyTo);
24520
- if (typeof reply === "string") return {
24521
- error: reply,
25525
+ const found = params.replyTo === void 0 ? void 0 : await this.replyFor(linkId, params.replyTo);
25526
+ if (typeof found === "string") return {
25527
+ error: found,
24522
25528
  refused: true
24523
25529
  };
25530
+ if (params.button !== void 0 && !(typeof params.button === "string" && BUTTON_ID.test(params.button) && found)) return {
25531
+ error: "No such button",
25532
+ refused: true
25533
+ };
25534
+ const reply = found && params.button !== void 0 ? {
25535
+ ...found,
25536
+ button: params.button
25537
+ } : found;
25538
+ if (card) return this.sendChatText(live, trimmed, timestamp, void 0, reply, void 0, card);
24524
25539
  return this.sendChatText(live, trimmed, timestamp, params.preview === void 0 ? void 0 : parseLinkPreview(params.preview, trimmed), reply);
24525
25540
  }
25541
+ if (card) return {
25542
+ error: "Status cards need a current chat; this compatibility chat sends text only.",
25543
+ refused: true
25544
+ };
24526
25545
  if (params.replyTo !== void 0) return {
24527
25546
  error: "Replies need a current chat; this compatibility chat sends text only.",
24528
25547
  refused: true
@@ -24568,19 +25587,27 @@ var GhostlyNode = class GhostlyNode {
24568
25587
  * both sides allow it; otherwise kept as `waiting` ("Sends when live") and sent by itself, in order, once
24569
25588
  * the chat can carry it. Only what must never wait, or a security stop, is refused.
24570
25589
  */
24571
- async sendChatText(live, trimmed, timestamp, preview, reply, forwarded) {
25590
+ async sendChatText(live, trimmed, timestamp, preview, reply, forwarded, card) {
24572
25591
  const { link } = live, linkId = live.stored.id;
24573
25592
  if (!link) return { error: "You are offline" };
24574
25593
  const bytes = new TextEncoder().encode(trimmed).length;
24575
- if (bytes > LIMITS.maxChatMessageBytes) return { error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.` };
25594
+ if (bytes > LIMITS.maxChatMessageBytes) return {
25595
+ error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`,
25596
+ refused: true
25597
+ };
24576
25598
  const stop = this.chatStopped(live);
24577
25599
  if (stop) return { error: stop };
25600
+ if (!link.isDataLinkOpen && link.dhtDelivery?.inviteTaken) return {
25601
+ error: INVITE_TAKEN,
25602
+ refused: true
25603
+ };
24578
25604
  const wireId = toBase64Url(randomBytes(16)), id = `me_${wireId}`;
24579
25605
  const delivery = link.isDataLinkOpen ? "stream" : link.textDelivery === "dht" ? "dht" : "unavailable";
24580
25606
  if (delivery !== "stream") this.wakePeer(live);
24581
25607
  const answers = {
24582
25608
  ...reply && { replyTo: reply },
24583
- ...forwarded && { forwarded }
25609
+ ...forwarded && { forwarded },
25610
+ ...card && { card }
24584
25611
  };
24585
25612
  if (delivery === "stream" || delivery === "dht" && bytes <= 256) {
24586
25613
  const validationError = link.validateText(trimmed, timestamp, wireId, reply && pairedWireReply(reply));
@@ -24619,7 +25646,8 @@ var GhostlyNode = class GhostlyNode {
24619
25646
  via: "hold",
24620
25647
  delivery: "sending",
24621
25648
  ...preview && { preview },
24622
- ...answers
25649
+ ...answers,
25650
+ ...card?.kind === "buttons" && { buttonsRestore: "due" }
24623
25651
  });
24624
25652
  this.hold.hold(linkId, {
24625
25653
  kind: "text",
@@ -24673,15 +25701,22 @@ var GhostlyNode = class GhostlyNode {
24673
25701
  async resolveReply(message) {
24674
25702
  const reply = message.replyTo;
24675
25703
  if (!reply || reply.messageId) return message;
24676
- const original = (await db.getMessages(message.linkId)).find((m) => m.id !== message.id && replyRef(m) === reply.id);
25704
+ const history = await db.getMessages(message.linkId);
25705
+ const original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
24677
25706
  if (!original) return message;
24678
- return {
25707
+ const resolved = {
24679
25708
  ...message,
24680
25709
  replyTo: {
24681
25710
  ...replyTo(original, reply.id),
24682
- ...reply.member && !original.member && { member: reply.member }
25711
+ ...reply.member && !original.member && { member: reply.member },
25712
+ ...reply.button && { button: reply.button }
24683
25713
  }
24684
25714
  };
25715
+ const press = buttonPress(resolved, original, history);
25716
+ return press ? {
25717
+ ...resolved,
25718
+ press
25719
+ } : resolved;
24685
25720
  }
24686
25721
  /** A paired row's reply as it goes on the wire again (a resend, a hold). */
24687
25722
  /** `localStorage["ghostly-test-no-edit"]`: this app offers no edit/1, as an older one (the e2e's old contact). Nothing else reads it. */
@@ -24695,6 +25730,15 @@ var GhostlyNode = class GhostlyNode {
24695
25730
  static wireReply(message) {
24696
25731
  return message.replyTo && pairedWireReply(message.replyTo);
24697
25732
  }
25733
+ /** The chat is on the DHT by choice: this side's DHT only, or the contact's (its records say so). It goes live for neither. */
25734
+ static dhtByChoice(live) {
25735
+ return live?.stored.deliveryMode === "dht" || live?.link?.dhtDelivery?.peerMode === "dht";
25736
+ }
25737
+ /** A status card a caller asks to send, by the sender's rule (WISP 4xx · Status Cards), or why it cannot go. */
25738
+ static cardToSend(raw) {
25739
+ const checked = checkStatusCard(raw);
25740
+ return "error" in checked ? `Status card: ${checked.error}` : checked.card;
25741
+ }
24698
25742
  /**
24699
25743
  * A chat stopped by a security rejection (a stream authenticated a participation key other than the pinned one):
24700
25744
  * nothing goes until the person acts. Another key on the DHT or the link's signals is ignored instead (WISP 400).
@@ -24714,7 +25758,16 @@ var GhostlyNode = class GhostlyNode {
24714
25758
  error,
24715
25759
  refused: true
24716
25760
  });
24717
- if (typeof linkId === "string" && linkId.startsWith("group:")) return this.groupEdits.edit(linkId.slice(6), params.messageId, params.text, Array.isArray(params.mentions) ? params.mentions : []);
25761
+ const card = params.card === void 0 ? void 0 : GhostlyNode.cardToSend(params.card);
25762
+ if (typeof card === "string") return refuse(card);
25763
+ if (card && !(typeof params.text === "string" && params.text.trim())) params = {
25764
+ ...params,
25765
+ text: statusCardText(card)
25766
+ };
25767
+ if (typeof linkId === "string" && linkId.startsWith("group:")) {
25768
+ const mentions = Array.isArray(params.mentions) ? params.mentions : [];
25769
+ return card ? this.groupEdits.edit(linkId.slice(6), params.messageId, params.text, mentions, card) : this.groupEdits.edit(linkId.slice(6), params.messageId, params.text, mentions);
25770
+ }
24718
25771
  const live = this.links.get(linkId);
24719
25772
  if (!live) return refuse("No such chat");
24720
25773
  if (!live.stored.profile) return refuse("Editing needs a current chat; this compatibility chat cannot edit.");
@@ -24728,24 +25781,26 @@ var GhostlyNode = class GhostlyNode {
24728
25781
  if (new TextEncoder().encode(text).length > LIMITS.maxChatMessageBytes) return refuse(`Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`);
24729
25782
  const stop = this.chatStopped(live);
24730
25783
  if (stop) return { error: stop };
24731
- const preview = params.preview === void 0 ? void 0 : parseLinkPreview(params.preview, text);
24732
- if (text === message.text && (preview?.u ?? "") === (message.preview?.u ?? "")) return {
25784
+ const preview = card || params.preview === void 0 ? void 0 : parseLinkPreview(params.preview, text);
25785
+ if (text === message.text && (preview?.u ?? "") === (message.preview?.u ?? "") && JSON.stringify(card ?? null) === JSON.stringify(message.card ?? null)) return {
24733
25786
  error: null,
24734
25787
  messageId: message.id
24735
25788
  };
24736
- const seq = (message.edit?.seq ?? 0) + 1;
24737
- if (seq > 100) return refuse(`This message was edited 100 times, the most one takes.`);
25789
+ const seq = (message.edit?.seq ?? 0) + 1, most = card ? STATUS_CARD_LIMITS.edits : 100;
25790
+ if (seq > most) return refuse(`This message was edited ${most} times, the most one takes.`);
24738
25791
  const edited = withEdit(message, {
24739
25792
  seq,
24740
25793
  at: Date.now(),
24741
25794
  text,
24742
25795
  preview,
25796
+ card,
24743
25797
  pending: true
24744
25798
  });
24745
25799
  await db.patchMessage(linkId, message.id, () => ({
24746
25800
  text: edited.text,
24747
25801
  edit: edited.edit,
24748
- preview: edited.preview
25802
+ preview: edited.preview,
25803
+ card: edited.card
24749
25804
  }));
24750
25805
  await this.messagesChanged(linkId, [message.id]);
24751
25806
  this.editsFor(linkId).flush().catch(() => {});
@@ -24772,21 +25827,30 @@ var GhostlyNode = class GhostlyNode {
24772
25827
  await this.applyPeerEdit(linkId, message, edit);
24773
25828
  return true;
24774
25829
  }
24775
- /** The contact's edit on its message here, when it is newer than what shows. Not a new message: no sound, no unread, no move. */
25830
+ /**
25831
+ * The contact's edit on its message here, when it is newer than what shows. Not a new message: no sound, no unread, no
25832
+ * move. A card's update is paced: at most one applied per message a second, the highest number winning.
25833
+ */
24776
25834
  async applyPeerEdit(linkId, message, edit) {
24777
25835
  if (!takesPeerEdit(message, edit.m) || (message.edit?.seq ?? 0) >= edit.e) return;
25836
+ if (edit.sc) return this.cardEdits.take(`${linkId}\n${message.id}`, edit.e, () => this.applyPeerEditNow(linkId, message, edit));
25837
+ return this.applyPeerEditNow(linkId, message, edit);
25838
+ }
25839
+ async applyPeerEditNow(linkId, message, edit) {
24778
25840
  if (await db.patchMessage(linkId, message.id, (current) => {
24779
25841
  if (!takesPeerEdit(current, edit.m) || (current.edit?.seq ?? 0) >= edit.e) return null;
24780
25842
  const next = withEdit(current, {
24781
25843
  seq: edit.e,
24782
25844
  at: receivedTimestamp(edit.ts),
24783
25845
  text: edit.m,
24784
- preview: edit.pv
25846
+ preview: edit.pv,
25847
+ card: edit.sc
24785
25848
  });
24786
25849
  return {
24787
25850
  text: next.text,
24788
25851
  edit: next.edit,
24789
- preview: next.preview
25852
+ preview: next.preview,
25853
+ card: next.card
24790
25854
  };
24791
25855
  })) await this.messagesChanged(linkId, [message.id]);
24792
25856
  }
@@ -24794,6 +25858,53 @@ var GhostlyNode = class GhostlyNode {
24794
25858
  takesDhtEdits(live) {
24795
25859
  return !!live?.caps?.peer?.capabilities.includes(EDIT_CAPABILITY);
24796
25860
  }
25861
+ /**
25862
+ * A question of mine with buttons went on the DHT floor or into a hold, which carry its text alone (WISP 4xx · Message
25863
+ * Buttons): its buttons are due to go again live. Once: a row that had them restored already stays so.
25864
+ */
25865
+ async buttonsWentBare(linkId, message) {
25866
+ if (message.card?.kind !== "buttons" || message.buttonsRestore) return;
25867
+ await db.patchMessage(linkId, message.id, (current) => current.sender === "me" && current.card?.kind === "buttons" && !current.buttonsRestore ? { buttonsRestore: "due" } : null);
25868
+ }
25869
+ /**
25870
+ * Buttons the contact may not have (WISP 4xx · Message Buttons): a question whose text went on the DHT floor or into a
25871
+ * hold reached the contact as text alone, and a copy under the same id that comes live later is taken as the one
25872
+ * already there. Once the chat is live with an app that shows buttons (`buttons/1`) and takes edits, each such
25873
+ * question goes again as an edit of its buttons alone: the same text, so no version and no edit mark. Once per
25874
+ * message (the row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later
25875
+ * edit of the bot's replaces it, the highest number winning). An app without `buttons/1` (1.0.0 would mark it edited)
25876
+ * gets none: the row waits for one that shows buttons. The edit is marked `restore`: the bot's own event stream says
25877
+ * nothing of it, while its number counts, so a later edit of the bot's takes the next one.
25878
+ */
25879
+ async restoreButtons(linkId) {
25880
+ const live = this.links.get(linkId), link = live?.link;
25881
+ if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !link.sessionOffers.peer?.includes("buttons/1")) return;
25882
+ const due = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && m.buttonsRestore === "due");
25883
+ const changed = [];
25884
+ for (const message of due) {
25885
+ const at = Date.now();
25886
+ if ((await db.patchMessage(linkId, message.id, (current) => {
25887
+ if (current.sender !== "me" || current.buttonsRestore !== "due") return null;
25888
+ const seq = (current.edit?.seq ?? 0) + 1;
25889
+ if (current.card?.kind !== "buttons" || !current.wireId || seq > STATUS_CARD_LIMITS.edits) return { buttonsRestore: "sent" };
25890
+ return {
25891
+ edit: {
25892
+ ...withEdit(current, {
25893
+ seq,
25894
+ at,
25895
+ text: current.text,
25896
+ preview: current.preview,
25897
+ card: current.card,
25898
+ pending: true
25899
+ }).edit,
25900
+ restore: true
25901
+ },
25902
+ buttonsRestore: "sent"
25903
+ };
25904
+ }))?.edit?.pending) changed.push(message.id);
25905
+ }
25906
+ if (changed.length) await this.messagesChanged(linkId, changed);
25907
+ }
24797
25908
  /** My edits in one 1:1 chat on their way to the contact. */
24798
25909
  editsFor(linkId) {
24799
25910
  let queue = this.editQueues.get(linkId);
@@ -24891,8 +26002,9 @@ var GhostlyNode = class GhostlyNode {
24891
26002
  if (!link) return "You are offline. It is sent again once you are back.";
24892
26003
  const at = Date.now(), snapshot = pathSnapshot(live, message.via);
24893
26004
  const reply = GhostlyNode.wireReply(message);
24894
- const error = message.forwarded ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded) : reply ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply) : await link.sendMessage(message.text, message.timestamp, message.wireId, ...message.preview ? [message.preview] : []);
26005
+ const error = message.card ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded, message.card) : message.forwarded ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded) : reply ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply) : await link.sendMessage(message.text, message.timestamp, message.wireId, ...message.preview ? [message.preview] : []);
24895
26006
  await this.noteTextSend(linkId, message, snapshot, at, error, link);
26007
+ if (!error && message.via === "pkarr") await this.buttonsWentBare(linkId, message);
24896
26008
  return error;
24897
26009
  }, (message) => message.via === "pkarr" ? DHT_MESSAGE_TTL : 2e4, (message) => {
24898
26010
  const pending = this.links.get(linkId)?.stored.dhtDeliveryState?.pending;
@@ -24900,10 +26012,10 @@ var GhostlyNode = class GhostlyNode {
24900
26012
  }, { resender: {
24901
26013
  ready: (message) => {
24902
26014
  const live = this.links.get(linkId), link = live?.link;
24903
- if (message.via === "pkarr" && live?.stored.deliveryMode !== "dht" && link?.textDelivery !== "stream") return false;
26015
+ if (message.via === "pkarr" && !GhostlyNode.dhtByChoice(live) && link?.textDelivery !== "stream") return false;
24904
26016
  return !!link?.canSendText && !!message.wireId && !link.validateText(message.text, message.timestamp, message.wireId, GhostlyNode.wireReply(message));
24905
26017
  },
24906
- requeueExpired: () => this.links.get(linkId)?.stored.deliveryMode !== "dht",
26018
+ requeueExpired: () => !GhostlyNode.dhtByChoice(this.links.get(linkId)),
24907
26019
  via: (message) => {
24908
26020
  const delivery = this.links.get(linkId)?.link?.textDelivery;
24909
26021
  return delivery === "dht" ? "pkarr" : delivery === "stream" ? "datalink" : message.via;
@@ -24915,6 +26027,7 @@ var GhostlyNode = class GhostlyNode {
24915
26027
  via: "hold",
24916
26028
  resendUntil: void 0
24917
26029
  });
26030
+ await this.buttonsWentBare(linkId, message);
24918
26031
  await this.messagesChanged(linkId, [message.id]);
24919
26032
  this.hold.hold(linkId, {
24920
26033
  kind: "text",
@@ -24996,7 +26109,7 @@ var GhostlyNode = class GhostlyNode {
24996
26109
  frame: "paired-message",
24997
26110
  protocol: "chat/1",
24998
26111
  plaintextBytes,
24999
- wireBytes: utf8Encode(pairedMessageFrame(message.wireId ?? "", message.timestamp, message.text, message.preview, GhostlyNode.wireReply(message))).length
26112
+ wireBytes: utf8Encode(pairedMessageFrame(message.wireId ?? "", message.timestamp, message.text, message.preview, GhostlyNode.wireReply(message), message.forwarded, message.card)).length
25000
26113
  };
25001
26114
  return this.noteDetails(linkId, message.id, (details) => ({
25002
26115
  ...withSend(details, send),
@@ -25059,7 +26172,7 @@ var GhostlyNode = class GhostlyNode {
25059
26172
  frame: "paired-message",
25060
26173
  protocol: "chat/1",
25061
26174
  plaintextBytes,
25062
- wireBytes: utf8Encode(pairedMessageFrame(message.id ?? "", message.timestamp, message.text, message.preview)).length
26175
+ wireBytes: utf8Encode(pairedMessageFrame(message.id ?? "", message.timestamp, message.text, message.preview, void 0, message.forwarded, message.card)).length
25063
26176
  } };
25064
26177
  }
25065
26178
  /** A held item's fate, on its message: stored for the contact (with the item's place in the mailbox), failed, or picked up. */
@@ -25115,6 +26228,31 @@ var GhostlyNode = class GhostlyNode {
25115
26228
  completedAt: now,
25116
26229
  ...message.sender === "me" && { receiptAt: now }
25117
26230
  });
26231
+ if (!error && message.sender === "me" && message.delivery !== "delivered") {
26232
+ if (await db.patchMessage(linkId, message.id, (m) => m.delivery === "delivered" ? null : {
26233
+ delivery: "delivered",
26234
+ deliveryError: void 0
26235
+ })) {
26236
+ await this.messagesChanged(linkId, [message.id]);
26237
+ this.emitState();
26238
+ }
26239
+ }
26240
+ }
26241
+ /**
26242
+ * The contact's app answered for a payment of mine (`pay-res`): its message gets two ticks, as a text gets from its
26243
+ * receipt. A payment goes with no delivery state of its own, so its mark read one tick for good. 1:1 chats only: a
26244
+ * group's payments live in the group's history, under other ids.
26245
+ */
26246
+ async notePaymentReceipt(linkId, paymentId) {
26247
+ const message = (await db.getMessages(linkId)).find((m) => m.sender === "me" && m.paymentId === paymentId);
26248
+ if (!message || message.delivery === "delivered") return;
26249
+ if (await db.patchMessage(linkId, message.id, (m) => m.delivery === "delivered" ? null : {
26250
+ delivery: "delivered",
26251
+ deliveryError: void 0
26252
+ })) {
26253
+ await this.messagesChanged(linkId, [message.id]);
26254
+ this.emitState();
26255
+ }
25118
26256
  }
25119
26257
  deleteMessage({ linkId, messageId }) {
25120
26258
  const live = this.links.get(linkId);
@@ -25395,6 +26533,7 @@ var GhostlyNode = class GhostlyNode {
25395
26533
  transferred: 0,
25396
26534
  size: file.size
25397
26535
  });
26536
+ this.flushState();
25398
26537
  const first = `peer_${wire.timestamp}`, own = `${first}_${wire.id}`;
25399
26538
  const messageIds = files.messageIds ??= /* @__PURE__ */ new Map();
25400
26539
  const holder = messageIds.get(first);
@@ -25717,16 +26856,22 @@ var GhostlyNode = class GhostlyNode {
25717
26856
  if (!this.settings.online) throw new Error("Go online to join a group");
25718
26857
  return { groupId: await this.groups.joinByLink(link) };
25719
26858
  }
25720
- async sendGroupMessage({ groupId, text, mentions, replyTo }) {
26859
+ async sendGroupMessage({ groupId, text: given, mentions, replyTo, card: raw, button }) {
26860
+ const card = raw === void 0 ? void 0 : GhostlyNode.cardToSend(raw);
26861
+ if (typeof card === "string") return { error: card };
26862
+ const text = card && !(typeof given === "string" && given.trim()) ? statusCardText(card) : given;
25721
26863
  if (typeof text !== "string") return { error: "Nothing to send" };
25722
26864
  const reply = replyTo === void 0 ? void 0 : await this.replyFor(`group:${groupId}`, replyTo);
25723
26865
  if (typeof reply === "string") return { error: reply };
25724
26866
  const named = Array.isArray(mentions) ? mentions : [];
25725
- const sent = await this.groups.send(groupId, text, named, reply && {
26867
+ if (button !== void 0 && !(typeof button === "string" && BUTTON_ID.test(button) && reply)) return { error: "No such button" };
26868
+ const wireReply = reply && {
25726
26869
  i: reply.id,
25727
26870
  s: reply.snippet,
25728
- f: reply.member
25729
- });
26871
+ f: reply.member,
26872
+ ...button !== void 0 && { b: button }
26873
+ };
26874
+ const sent = card ? await this.groups.send(groupId, text, named, wireReply, void 0, card) : await this.groups.send(groupId, text, named, wireReply);
25730
26875
  if (!sent.error) this.wakeMentioned(groupId, text, named);
25731
26876
  return sent;
25732
26877
  }
@@ -25734,6 +26879,59 @@ var GhostlyNode = class GhostlyNode {
25734
26879
  return this.groups.messages(groupId);
25735
26880
  }
25736
26881
  /**
26882
+ * Presses a button of a message someone else sent to a chat or a group (`group:<id>`), WISP 4xx · Message Buttons:
26883
+ * a reply to it whose text is the button's label and whose `r` names the button, so the author learns who pressed
26884
+ * what, and an app without buttons reads an ordinary reply. Refused: my own message, a button it does not have,
26885
+ * buttons the author closed, a question I already answered with a `once` button, a second press within a second.
26886
+ */
26887
+ async pressButton(params) {
26888
+ const { linkId, messageId, buttonId } = params ?? {};
26889
+ if (typeof linkId !== "string" || typeof messageId !== "string" || typeof buttonId !== "string") return {
26890
+ error: "No button to press",
26891
+ refused: true
26892
+ };
26893
+ const history = await db.getMessages(linkId);
26894
+ const message = history.find((m) => m.id === messageId);
26895
+ if (!message) return {
26896
+ error: "That message is not in this chat",
26897
+ refused: true
26898
+ };
26899
+ if (message.sender === "me") return {
26900
+ error: "Buttons on your own message do nothing",
26901
+ refused: true
26902
+ };
26903
+ const state = buttonsState(message, replyRef(message), history);
26904
+ const button = state?.card.buttons.find((b) => b.id === buttonId);
26905
+ if (!state || !button) return {
26906
+ error: "That message has no such button",
26907
+ refused: true
26908
+ };
26909
+ if (!state.open) return {
26910
+ error: state.card.closed ? "These buttons are closed" : "You already answered",
26911
+ refused: true
26912
+ };
26913
+ const key = `${linkId}\n${messageId}`, now = Date.now();
26914
+ if (now - (this.pressedAt.get(key) ?? -Infinity) < 1e3) return {
26915
+ error: "One press a second",
26916
+ refused: true,
26917
+ paced: true
26918
+ };
26919
+ if (this.pressedAt.size > 500) this.pressedAt.clear();
26920
+ this.pressedAt.set(key, now);
26921
+ if (linkId.startsWith("group:")) return this.sendGroupMessage({
26922
+ groupId: linkId.slice(6),
26923
+ text: button.label,
26924
+ replyTo: messageId,
26925
+ button: button.id
26926
+ });
26927
+ return this.sendMessage({
26928
+ linkId,
26929
+ text: button.label,
26930
+ replyTo: messageId,
26931
+ button: button.id
26932
+ });
26933
+ }
26934
+ /**
25737
26935
  * Forwards messages of a chat or a group (`group:<id>`) to up to `FORWARD_TARGETS` others (WISP 400 § Forwards): each
25738
26936
  * as a new message of mine, in the order they were written, carrying one more hop than it had and nothing of who wrote
25739
26937
  * it or where. A text goes as written; a file from the bytes on this device (a copy per chat, nothing fetched again),
@@ -26158,8 +27356,19 @@ var GhostlyNode = class GhostlyNode {
26158
27356
  if (!live.link) throw new GhostlyHttpError("offline", "Ghostly is offline");
26159
27357
  return live.link.request(serviceId, request);
26160
27358
  }
26161
- async walletAddMint({ url, primary }) {
27359
+ /**
27360
+ * `network`: the Cashu wallet it is added from. A mint's network is its own (a test mint is Testnet's, any other
27361
+ * Mainnet's): one of the other network is refused, never filed into that network's wallet (which it would make,
27362
+ * unseen from the wallet it was added from). A test mint or one on this machine is known by its address, and is
27363
+ * refused from Mainnet before it is contacted. Any other address is Mainnet's only by default: from Testnet it is
27364
+ * contacted first, so a typo or a server that is not a mint is named as such, and it is called a mint of real sats
27365
+ * only once it answered as a mint.
27366
+ */
27367
+ async walletAddMint({ url, primary, network }) {
27368
+ const other = !!network && mintNetwork(normalizeMintUrl(url)) !== network;
27369
+ if (other && network === "mainnet") throw engineError("testMintOnMainnet");
26162
27370
  const mint = await this.wallet.checkMint(url);
27371
+ if (other) throw engineError("realMintOnTestnet");
26163
27372
  const others = this.settings.mints.filter((m) => m !== mint.url);
26164
27373
  const known = others.length !== this.settings.mints.length;
26165
27374
  if (primary) await this.updateSettings({ settings: { mints: [mint.url, ...others] } });
@@ -26215,6 +27424,11 @@ var GhostlyNode = class GhostlyNode {
26215
27424
  await this.refreshWallet();
26216
27425
  const made = this.walletView.wallets?.find((w) => w.type === type && w.network === network && (card === void 0 || w.card === card));
26217
27426
  if (!made) throw new Error(`The ${label} wallet did not come up. Nothing was lost: try again.`);
27427
+ await this.walletSetup.made(type, network);
27428
+ this.walletView = {
27429
+ ...this.walletView,
27430
+ setup: this.walletSetup.view()
27431
+ };
26218
27432
  return made;
26219
27433
  }
26220
27434
  /**
@@ -26286,8 +27500,8 @@ var GhostlyNode = class GhostlyNode {
26286
27500
  await this.refreshWallet();
26287
27501
  const card = type === "lightning" ? asked ?? this.lightnings[network].receivingId : void 0;
26288
27502
  const lightningName = card !== void 0 ? this.walletView.networks?.[network].lightnings?.find((c) => c.card === card)?.name : void 0;
26289
- const label = lightningName ? `${networkLabel(network)} Lightning card “${lightningName}”` : `${networkLabel(network)} ${WALLET_NAMES[type]}`;
26290
- if (!this.walletView.wallets?.some((w) => w.type === type && w.network === network && (card === void 0 || w.card === card))) throw new Error(`There is no ${label} wallet to remove`);
27503
+ const label = lightningName ? `${networkLabel(network)} Lightning card “${lightningName}”` : `${networkLabel(network)} ${WALLET_NAMES[type]} wallet`;
27504
+ if (!this.walletView.wallets?.some((w) => w.type === type && w.network === network && (card === void 0 || w.card === card))) throw new Error(`There is no ${label} to remove`);
26291
27505
  const first = walletRemoval(type, network, this.walletView.networks?.[network], this.walletView.intents, card);
26292
27506
  if (first.comesWith) throw new Error(`Lightning through the Cashu mints comes with your ${networkLabel(network)} Cashu wallet: remove that wallet to remove it`);
26293
27507
  if (first.awaiting.length) {
@@ -26297,7 +27511,7 @@ var GhostlyNode = class GhostlyNode {
26297
27511
  const removal = first.awaiting.length ? walletRemoval(type, network, this.walletView.networks?.[network], this.walletView.intents, card) : first;
26298
27512
  if (removal.pending) throw new Error(`A payment through this wallet is not finished yet (${removal.pending}). Cancel it or wait for it to settle, then remove the wallet.`);
26299
27513
  if (removalRisksFunds(removal) && acceptLoss !== true) throw new Error(lossRefusal(label, removal));
26300
- for (const item of removal.awaiting) if (item.kind === "request" && item.paymentId) await this.closeRequest(item.paymentId, `you removed the ${label} wallet it was paid to`).catch(() => {});
27514
+ for (const item of removal.awaiting) if (item.kind === "request" && item.paymentId) await this.closeRequest(item.paymentId, `you removed the ${label} it was paid to`).catch(() => {});
26301
27515
  try {
26302
27516
  if (type === "cashu") {
26303
27517
  const mints = this.networkMints(network);
@@ -26314,6 +27528,11 @@ var GhostlyNode = class GhostlyNode {
26314
27528
  await this.refreshWallet();
26315
27529
  }
26316
27530
  if (!this.walletView.wallets?.some((w) => w.type === type && w.network === network)) await this.forgetChatNetwork(type, network);
27531
+ const reminders = this.settings.backupReminders;
27532
+ if (reminders && type !== "lightning" && `${type}:${network}` in reminders) {
27533
+ await this.saveBackups(forgetWallet(reminders, `${type}:${network}`));
27534
+ this.emitState();
27535
+ }
26317
27536
  }
26318
27537
  /**
26319
27538
  * Before a wallet goes: its own rail is asked about what was paid to it (the mints about its quotes, the source about
@@ -26467,8 +27686,41 @@ var GhostlyNode = class GhostlyNode {
26467
27686
  await this.updateSettings({ settings: { mints: [url, ...this.settings.mints.filter((m) => m !== url)] } });
26468
27687
  await this.refreshWallet();
26469
27688
  }
26470
- async walletRemoveMint({ url }) {
26471
- if (await this.wallet.balanceAt(url) > 0) throw new Error("Move your sats out of this mint before removing it");
27689
+ /**
27690
+ * Removes one mint of a Cashu wallet. Never one that holds sats, nor a network's last mint (that removes the Cashu
27691
+ * wallet: `walletRemove`). One that still waits for money (an invoice not paid yet, paid ecash not claimed) goes
27692
+ * only with `acceptLoss`: what is paid to it afterwards is claimed, but shows only once the mint is added again.
27693
+ */
27694
+ async walletRemoveMint({ url, acceptLoss }) {
27695
+ const network = mintNetwork(url);
27696
+ const mints = this.networkMints(network);
27697
+ if (mints.length === 1 && mints[0] === url) throw engineError("lastMint", { network: networkLabel(network) });
27698
+ if (await this.wallet.balanceAt(url) > 0) throw engineError("mintHoldsSats");
27699
+ const waits = async () => mintAwaiting(url, {
27700
+ network,
27701
+ quotes: await this.wallet.quotes(),
27702
+ payments: this.desk.records(),
27703
+ now: Date.now()
27704
+ });
27705
+ if ((await waits()).length) {
27706
+ let timer;
27707
+ await Promise.race([this.wallet.checkQuotes([url]).catch(() => {}), new Promise((resolve) => {
27708
+ timer = setTimeout(resolve, removalTiming.claimMs);
27709
+ })]);
27710
+ clearTimeout(timer);
27711
+ if (await this.wallet.balanceAt(url) > 0) {
27712
+ await this.refreshWallet();
27713
+ throw engineError("mintHoldsSats");
27714
+ }
27715
+ const awaiting = await waits();
27716
+ if (awaiting.length && acceptLoss !== true) {
27717
+ const listed = awaiting.map((a) => {
27718
+ const text = ENGLISH_REMOVAL.item(a.kind, ENGLISH_REMOVAL.sats(a.amount, network));
27719
+ return text.charAt(0).toLowerCase() + text.slice(1);
27720
+ }).join("; ");
27721
+ throw new Error(`This mint still waits for money: ${listed}. Confirm that what is paid to it afterwards shows only once you add the mint again to remove it.`);
27722
+ }
27723
+ }
26472
27724
  await this.updateSettings({ settings: { mints: this.settings.mints.filter((m) => m !== url) } });
26473
27725
  await this.refreshWallet();
26474
27726
  }
@@ -26480,6 +27732,7 @@ var GhostlyNode = class GhostlyNode {
26480
27732
  const n = this.net(network);
26481
27733
  if (via === "cashu") {
26482
27734
  const quote = await this.wallet.receiveLightning(amount, void 0, n);
27735
+ this.awaitingSoon();
26483
27736
  return {
26484
27737
  quote: quote.quote,
26485
27738
  invoice: quote.invoice,
@@ -26488,6 +27741,7 @@ var GhostlyNode = class GhostlyNode {
26488
27741
  };
26489
27742
  }
26490
27743
  const created = await (await this.lightningCard(n, card)).createInvoice(amount);
27744
+ this.awaitingSoon();
26491
27745
  return {
26492
27746
  quote: created.paymentHash,
26493
27747
  invoice: created.invoice,
@@ -26642,7 +27896,7 @@ var GhostlyNode = class GhostlyNode {
26642
27896
  return this.usdtWallets[this.net(params.network)].unlock(params.password);
26643
27897
  }
26644
27898
  usdtReveal(params) {
26645
- return this.usdtWallets[this.net(params?.network)].reveal(params?.password);
27899
+ return this.copied("usdt", params?.network, () => this.usdtWallets[this.net(params?.network)].reveal(params?.password));
26646
27900
  }
26647
27901
  usdtLock(params) {
26648
27902
  return this.usdtWallets[this.net(params?.network)].lock();
@@ -26651,7 +27905,7 @@ var GhostlyNode = class GhostlyNode {
26651
27905
  return this.usdtWallets[this.net(params?.network)].refresh();
26652
27906
  }
26653
27907
  usdtExportBackup(params) {
26654
- return this.usdtWallets[this.net(params.network)].exportBackup(params.password);
27908
+ return this.copied("usdt", params.network, () => this.usdtWallets[this.net(params.network)].exportBackup(params.password));
26655
27909
  }
26656
27910
  async usdtRestoreBackup(params) {
26657
27911
  const { wallet } = await this.restoreInto(this.usdtWallets, params.network, (w) => w.restoreBackup(params.text, params.password));
@@ -26667,10 +27921,10 @@ var GhostlyNode = class GhostlyNode {
26667
27921
  return this.arkWallets[this.net(params?.network)].lock();
26668
27922
  }
26669
27923
  arkBackup(params) {
26670
- return this.arkWallets[this.net(params?.network)].backup(params?.password);
27924
+ return this.copied("arkade", params?.network, () => this.arkWallets[this.net(params?.network)].backup(params?.password));
26671
27925
  }
26672
27926
  arkExportBackup(params) {
26673
- return this.arkWallets[this.net(params.network)].exportBackup(params.password);
27927
+ return this.copied("arkade", params.network, () => this.arkWallets[this.net(params.network)].exportBackup(params.password));
26674
27928
  }
26675
27929
  async arkRestoreBackup(params) {
26676
27930
  const { wallet } = await this.restoreInto(this.arkWallets, params.network, (w) => w.restoreBackup(params.text, params.password));
@@ -26686,10 +27940,10 @@ var GhostlyNode = class GhostlyNode {
26686
27940
  return this.barkWallets[barkMode(params.network)].create(params);
26687
27941
  }
26688
27942
  barkBackup(params) {
26689
- return this.barkWallets[this.net(params?.network)].backup();
27943
+ return this.copied("bark", params?.network, () => this.barkWallets[this.net(params?.network)].backup());
26690
27944
  }
26691
27945
  barkExportBackup(params) {
26692
- return this.barkWallets[this.net(params.network)].exportBackup(params.password);
27946
+ return this.copied("bark", params.network, () => this.barkWallets[this.net(params.network)].exportBackup(params.password));
26693
27947
  }
26694
27948
  async barkRestoreBackup(params) {
26695
27949
  const { wallet } = await this.restoreInto(this.barkWallets, params.network, (w) => w.restoreBackup(params.text, params.password));
@@ -26742,10 +27996,10 @@ var GhostlyNode = class GhostlyNode {
26742
27996
  return (this.fedimintOf(params.federation) ?? this.fedimintWallets.mainnet).takeBack(params.federation, params.operation);
26743
27997
  }
26744
27998
  fedimintBackup(params) {
26745
- return this.fedimintWallets[this.net(params?.network)].backup();
27999
+ return this.copied("fedimint", params?.network, () => this.fedimintWallets[this.net(params?.network)].backup());
26746
28000
  }
26747
28001
  fedimintExportBackup(params) {
26748
- return this.fedimintWallets[this.net(params.network)].exportBackup(params.password);
28002
+ return this.copied("fedimint", params.network, () => this.fedimintWallets[this.net(params.network)].exportBackup(params.password));
26749
28003
  }
26750
28004
  async fedimintRestoreBackup(params) {
26751
28005
  return (await this.restoreInto(this.fedimintWallets, params.network, (w) => w.restoreBackup(params.text, params.password))).result;
@@ -26758,14 +28012,14 @@ var GhostlyNode = class GhostlyNode {
26758
28012
  await this.sparkWallets[sparkMode(params.network)].create(params);
26759
28013
  }
26760
28014
  async sparkBackup(params) {
26761
- const { mnemonic, network } = await this.sparkWallets[this.net(params?.network)].backup();
28015
+ const { mnemonic, network } = await this.copied("spark", params?.network, () => this.sparkWallets[this.net(params?.network)].backup());
26762
28016
  return {
26763
28017
  mnemonic,
26764
28018
  network
26765
28019
  };
26766
28020
  }
26767
28021
  sparkExportBackup(params) {
26768
- return this.sparkWallets[this.net(params.network)].exportBackup(params.password);
28022
+ return this.copied("spark", params.network, () => this.sparkWallets[this.net(params.network)].exportBackup(params.password));
26769
28023
  }
26770
28024
  async sparkRestoreBackup(params) {
26771
28025
  const { wallet } = await this.restoreInto(this.sparkWallets, params.network, (w) => w.restoreBackup(params.text, params.password, params.apiKey));
@@ -26810,7 +28064,7 @@ var GhostlyNode = class GhostlyNode {
26810
28064
  if (params.requestId) {
26811
28065
  if (!request || request.linkId !== params.linkId || request.direction !== "in" || request.state !== "pending" || request.amount !== params.amount) throw new Error("Payment review does not match the authenticated request");
26812
28066
  if (params.target.method !== "cashu" ? JSON.stringify(request.target) !== JSON.stringify(params.target) : !!request.target || params.target.address !== request.id || !request.mints?.includes(params.target.provider)) throw new Error("Selected method or mint does not match the authenticated request");
26813
- if (request.lightningPending || Object.values(this.desk.views()).some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && !["failed", "reclaimed"].includes(p.state))) throw new Error("This request already has a payment; reconcile it instead");
28067
+ if (request.lightningPending || Object.values(this.desk.views()).some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && !["failed", "reclaimed"].includes(p.state))) throw engineError("requestPaymentInFlight");
26814
28068
  const asked = paymentNetwork(request);
26815
28069
  if (asked !== paying) throw new Error(crossNetwork(paying, asked));
26816
28070
  } else if (params.target.method !== "cashu" || !payee || params.target.address !== payee) throw new Error("Destination does not match the authenticated peer");
@@ -26843,18 +28097,18 @@ var GhostlyNode = class GhostlyNode {
26843
28097
  if (intent) assertConfirmedReal(intent.review.method === "cashu" ? mintNetwork(intent.review.provider) : walletNetworkOf(intent.review.network), params.confirmedReal);
26844
28098
  if (intent?.review.linkId) {
26845
28099
  const link = this.paymentLink(intent.review.linkId);
26846
- if (!link || intent.review.method === "arkade" && !link.supportsArkPayments) throw new Error("Reconnect the data link before approving. Your review was saved.");
28100
+ if (!link || intent.review.method === "arkade" && !link.supportsArkPayments) throw engineError("reconnectBeforeApprove");
28101
+ if (intent.review.requestId) {
28102
+ const request = this.desk.payment(intent.review.requestId);
28103
+ if (!request || request.state !== "pending" || request.lightningPending) throw engineError("requestNotAwaiting");
28104
+ }
28105
+ await link.requirePaymentSupport();
26847
28106
  if (intent.review.method === "usdt" && !link.supportsUsdtPayments) throw new Error("Reconnect a peer supporting USDT before approving");
26848
28107
  if (intent.review.method === "bark" && !link.supportsBarkPayments) throw new Error("Reconnect a peer supporting Bark before approving");
26849
28108
  if (intent.review.method === "bitcoin" && !link.supportsBitcoinPayments) throw new Error("Reconnect a peer taking on-chain Bitcoin before approving");
26850
28109
  if (intent.review.method === "fedimint" && !link.supportsFedimintPayments) throw new Error("Reconnect a peer taking Fedimint before approving");
26851
28110
  if (intent.review.method === "spark" && !link.supportsSparkPayments) throw new Error("Reconnect a peer taking Spark before approving");
26852
28111
  if (intent.review.method === "cashu" && !link.allowsPayment("cashu")) throw new Error("Cashu is off in this chat");
26853
- await link.requirePaymentSupport();
26854
- if (intent.review.requestId) {
26855
- const request = this.desk.payment(intent.review.requestId);
26856
- if (!request || request.state !== "pending" || request.lightningPending) throw new Error("This request is no longer awaiting payment. Check its status before spending.");
26857
- }
26858
28112
  }
26859
28113
  const review = await this.paymentCoordinator.approve(params.id);
26860
28114
  await this.desk.confirmReviewedCashu(review);
@@ -26880,6 +28134,19 @@ var GhostlyNode = class GhostlyNode {
26880
28134
  cancelPayment(params) {
26881
28135
  return this.paymentCoordinator.cancel(params.id);
26882
28136
  }
28137
+ /**
28138
+ * Reviews left pending that no Approve can send: past their expiry (a direct send reviewed and never approved, say),
28139
+ * or for a request paid some other way or closed meanwhile. Cancelled as Cancel does, so nothing they reserved stays
28140
+ * held and they leave the wallet's list of payments to finish; each stays in the store as cancelled. A review
28141
+ * approved, submitted or of unknown outcome is never touched.
28142
+ */
28143
+ async dropStaleReviews(now = Date.now()) {
28144
+ if (this.shuttingDown) return [];
28145
+ return this.paymentCoordinator.dropStale(now, (review) => {
28146
+ const request = review.requestId ? this.desk.payment(review.requestId) : void 0;
28147
+ return !!request && request.linkId === review.linkId && (request.state === "settled" || !!request.closed);
28148
+ }).catch(() => []);
28149
+ }
26883
28150
  /** `network`: the Cashu card of that network sends (its mints). `confirmedReal`: required on Mainnet. */
26884
28151
  sendPayment(params) {
26885
28152
  const live = this.links.get(params.linkId);
@@ -26980,6 +28247,7 @@ var GhostlyNode = class GhostlyNode {
26980
28247
  async updateSettings({ settings: patch }) {
26981
28248
  const { nostr, ...rest } = patch;
26982
28249
  const settings = rest;
28250
+ delete settings.backupReminders;
26983
28251
  const wasOnline = this.settings.online;
26984
28252
  for (const server of settings.iceServers ?? []) {
26985
28253
  const problem = iceServerProblem(server);
@@ -27012,6 +28280,7 @@ var GhostlyNode = class GhostlyNode {
27012
28280
  delete settings.wakeRotate;
27013
28281
  delete settings.wakeMutedGroups;
27014
28282
  delete settings.wakeHeldBy;
28283
+ delete settings.walletSetup;
27015
28284
  const relayBefore = this.hyperdhtRelay;
27016
28285
  if (nostr) {
27017
28286
  const current = effectiveNostrSettings(this.settings.nostr);
@@ -27094,7 +28363,7 @@ var GhostlyNode = class GhostlyNode {
27094
28363
  for (const live of this.links.values()) this.startLink(live.stored.id, await db.getMessages(live.stored.id));
27095
28364
  this.hold.start();
27096
28365
  this.startGroupEntries();
27097
- this.prepareSpare();
28366
+ this.prepareSpare(STARTUP_QUIET_MS);
27098
28367
  this.did.publishNow().catch(() => {});
27099
28368
  }
27100
28369
  if (wasOnline && !this.settings.online) this.stopGroupEntries();
@@ -27192,6 +28461,8 @@ var GhostlyNode = class GhostlyNode {
27192
28461
  if (!live?.stored.group) return;
27193
28462
  this.links.delete(linkId);
27194
28463
  this.groupWakeReceived.delete(linkId);
28464
+ this.groupNativeWaiting.delete(linkId);
28465
+ this.groupNativeRetryAt = 0;
27195
28466
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
27196
28467
  const group = live.stored.group, peer = live.stored.groupPeer;
27197
28468
  if (!this.membership(group)?.members.has(peer)) this.rotateWake().then(() => this.emitState());
@@ -27210,6 +28481,24 @@ var GhostlyNode = class GhostlyNode {
27210
28481
  await live.caps?.stop();
27211
28482
  await db.deleteLink(linkId);
27212
28483
  }
28484
+ /**
28485
+ * An edge's session opened or ended: kept, so that an app starting again knows which edges were live when it quit
28486
+ * (`edgeLive`, `resume`). Nothing is written while the app shuts down: an edge live then was live when it quit.
28487
+ */
28488
+ noteEdgeLive(linkId, up) {
28489
+ const live = this.links.get(linkId);
28490
+ if (!live || this.shuttingDown || !!live.stored.edgeLive === up) return;
28491
+ const since = up ? Date.now() : void 0;
28492
+ live.stored = {
28493
+ ...live.stored,
28494
+ edgeLive: up || void 0,
28495
+ edgeLiveSince: since
28496
+ };
28497
+ db.patchLink(linkId, {
28498
+ edgeLive: up || void 0,
28499
+ edgeLiveSince: since
28500
+ }).catch(() => {});
28501
+ }
27213
28502
  /** The edge of a group toward one member: a paired link pinned to that member's key, carrying group frames and nothing else. */
27214
28503
  async openEdge(state, peer, expectPeer = false) {
27215
28504
  const me = identityFromSeedB64(state.seedB64);
@@ -27278,7 +28567,10 @@ var GhostlyNode = class GhostlyNode {
27278
28567
  }
27279
28568
  startGroupEntries() {
27280
28569
  if (this.groupEntryTimer || this.shuttingDown || this.options.deferGroups) return;
27281
- this.groupEntryTimer = setInterval(() => void this.groups.tick().catch(() => {}), 1e3);
28570
+ this.groupEntryTimer = setInterval(() => {
28571
+ this.groups.tick().catch(() => {});
28572
+ this.retryGroupNative();
28573
+ }, 1e3);
27282
28574
  }
27283
28575
  stopGroupEntries() {
27284
28576
  if (this.groupEntryTimer) clearInterval(this.groupEntryTimer);
@@ -27291,6 +28583,8 @@ var GhostlyNode = class GhostlyNode {
27291
28583
  const group = stored.group, peer = stored.groupPeer, entry = !!stored.groupEntry;
27292
28584
  const role = stored.groupEntry ?? "edge";
27293
28585
  let seen = false;
28586
+ const native = this.keepsGroupNative(stored);
28587
+ const resumeOn = native ? TRANSPORTS.find((t) => t !== "webrtc/1" && t in this.nativeFactories && !!stored.peerTransports?.includes(t)) : "webrtc/1";
27294
28588
  live.pairing = { status: "connecting" };
27295
28589
  live.link = new GhostLink({
27296
28590
  paymentMethods: entry ? {
@@ -27308,6 +28602,15 @@ var GhostlyNode = class GhostlyNode {
27308
28602
  barkPaymentsSupport: !entry,
27309
28603
  params: stored,
27310
28604
  rtcAvailable: typeof RTCPeerConnection !== "undefined",
28605
+ resume: !entry && stored.edgeLive ? resumeOn : void 0,
28606
+ native: {
28607
+ peerDescriptors: stored.peerDescriptors,
28608
+ peerTransports: stored.peerTransports,
28609
+ peerFallback: stored.peerFallback,
28610
+ automatic: true
28611
+ },
28612
+ packetTransports: !this.options.webrtcGroupLinks,
28613
+ resumeFloor: !entry && stored.edgeLive ? stored.edgeLiveSince : void 0,
27311
28614
  pairing: {
27312
28615
  credentials: {
27313
28616
  seedB64: stored.participationSeed,
@@ -27340,6 +28643,7 @@ var GhostlyNode = class GhostlyNode {
27340
28643
  onGroupFrame: (frame) => entry ? this.groups.handleContactFrame(linkId, frame) : frame.t === "group-pay" ? this.groupPayments.receive(group, peer, frame) : frame.t === "group-wake" ? this.receiveGroupWake(linkId, frame) : this.groups.handleEdgeFrame(group, peer, frame),
27341
28644
  onGroupsSupport: (supported) => {
27342
28645
  if (supported) traceJoin(group, "link.ready", { role });
28646
+ if (!entry) this.noteEdgeLive(linkId, supported);
27343
28647
  if (supported) {
27344
28648
  if (entry) this.groups.entryReady(group, linkId, peer);
27345
28649
  else {
@@ -27352,6 +28656,19 @@ var GhostlyNode = class GhostlyNode {
27352
28656
  }
27353
28657
  this.emitState();
27354
28658
  },
28659
+ onPacketTransports: (peerTransports, peerDescriptors) => {
28660
+ const patch = {
28661
+ peerTransports,
28662
+ peerDescriptors,
28663
+ peerFallback: true
28664
+ };
28665
+ live.stored = {
28666
+ ...live.stored,
28667
+ ...patch
28668
+ };
28669
+ db.patchLink(linkId, patch).catch(() => {});
28670
+ if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
28671
+ },
27355
28672
  ...entry ? {} : {
27356
28673
  onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
27357
28674
  onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
@@ -27397,6 +28714,16 @@ var GhostlyNode = class GhostlyNode {
27397
28714
  });
27398
28715
  traceJoin(group, "link.start", { role });
27399
28716
  live.link.start();
28717
+ if (native) this.ensureNativeEndpoints(linkId);
28718
+ }
28719
+ /**
28720
+ * Whether a group's link (an edge, an entry session) runs native endpoints (WISP 9xx § Transports): where this app has
28721
+ * no WebRTC (the Linux Desktop), and where the member's has none, as its packet said. Between two apps that have
28722
+ * WebRTC it runs none, as before: a group of eight would otherwise hold seven listeners per transport for nothing.
28723
+ */
28724
+ keepsGroupNative(stored) {
28725
+ if (this.options.webrtcGroupLinks) return false;
28726
+ return typeof RTCPeerConnection === "undefined" || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
27400
28727
  }
27401
28728
  startLink(linkId, messages) {
27402
28729
  const live = this.links.get(linkId);
@@ -27428,6 +28755,8 @@ var GhostlyNode = class GhostlyNode {
27428
28755
  reactionsSupport: this.options.reactions !== false,
27429
28756
  editSupport: !GhostlyNode.testNoEdit(),
27430
28757
  pinSupport: true,
28758
+ statusCardSupport: true,
28759
+ buttonsSupport: true,
27431
28760
  wakeSupport: true,
27432
28761
  servicesSupport: this.options.servicesSupport ?? (this.options.platform ?? "web") !== "web",
27433
28762
  dht: stored.profile ? {
@@ -27439,6 +28768,7 @@ var GhostlyNode = class GhostlyNode {
27439
28768
  dhtDeliveryState: state
27440
28769
  };
27441
28770
  },
28771
+ firstControlAfterMs: STARTUP_QUIET_MS,
27442
28772
  capsRev: () => live.caps?.rev,
27443
28773
  peerCapsRev: (rev) => live.caps?.peerRev(rev),
27444
28774
  peerAcceptsText: () => {
@@ -27456,6 +28786,7 @@ var GhostlyNode = class GhostlyNode {
27456
28786
  automatic: stored.preferredTransport === void 0
27457
28787
  },
27458
28788
  resume: stored.pairedPeerKey && stored.deliveryMode !== "dht" ? this.transportLogOf(live)?.liveAtLastRun : void 0,
28789
+ resumeFloor: stored.pairedPeerKey && stored.deliveryMode !== "dht" ? this.transportLogOf(live)?.liveSinceAtLastRun : void 0,
27459
28790
  pairing: credentials ? {
27460
28791
  credentials,
27461
28792
  verifyPeer: async (key) => {
@@ -27545,7 +28876,10 @@ var GhostlyNode = class GhostlyNode {
27545
28876
  if (log.chose("contact", transport, Date.now())) this.saveTransportLog(live, log);
27546
28877
  },
27547
28878
  onTransportSwitched: () => {
27548
- if (stored.profile && !stored.group) this.outboxFor(linkId).flush({ reopened: true }).catch(() => {}).then(() => this.editsFor(linkId).flush({ reopened: true })).catch(() => {});
28879
+ if (stored.profile && !stored.group) {
28880
+ this.editsFor(linkId).reopened();
28881
+ this.outboxFor(linkId).flush({ reopened: true }).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
28882
+ }
27549
28883
  this.desk.replay(linkId).catch(() => {});
27550
28884
  this.observeTransport(linkId);
27551
28885
  },
@@ -27584,7 +28918,7 @@ var GhostlyNode = class GhostlyNode {
27584
28918
  live.pairing = state;
27585
28919
  if (state.status === "ready") this.identities.ready(linkId);
27586
28920
  else this.identities.closed(linkId);
27587
- if (state.status === "ready" && live.link) this.hold.rememberPeerMethods(linkId, PAYMENT_METHODS.filter((m) => live.link.peerAllowsPayment(m))).catch(() => {});
28921
+ if (state.status === "ready" && live.link) this.hold.rememberPeerMethods(linkId, PAYMENT_METHODS.filter((m) => live.link.peerChoosesPayment(m))).catch(() => {});
27588
28922
  live.proofs?.stop();
27589
28923
  this.observeTransport(linkId);
27590
28924
  this.emitState();
@@ -27610,7 +28944,7 @@ var GhostlyNode = class GhostlyNode {
27610
28944
  onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
27611
28945
  onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
27612
28946
  onEditSupport: (supported) => {
27613
- if (supported && stored.profile && !stored.group) this.editsFor(linkId).flush({ reopened: true }).catch(() => {});
28947
+ if (supported && stored.profile && !stored.group) this.restoreButtons(linkId).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
27614
28948
  },
27615
28949
  onWakeSupport: (supported) => {
27616
28950
  if (supported && stored.profile && !stored.group) this.shareWake(linkId);
@@ -27644,7 +28978,10 @@ var GhostlyNode = class GhostlyNode {
27644
28978
  }
27645
28979
  if (stored.profile && live.stored.deliveryMode !== "dht" && state !== "open") this.outboxFor(linkId).disconnected().catch(() => {});
27646
28980
  if (state !== "open" && was === "open") live.caps?.refresh();
27647
- if (stored.profile && !stored.group && state === "open") this.outboxFor(linkId).flush({ reopened: true }).catch(() => {}).then(() => this.editsFor(linkId).flush({ reopened: true })).catch(() => {});
28981
+ if (stored.profile && !stored.group && state === "open") {
28982
+ this.editsFor(linkId).reopened();
28983
+ this.outboxFor(linkId).flush({ reopened: true }).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
28984
+ }
27648
28985
  if (stored.profile && state !== "open" && live.pairing?.status !== "error") live.pairing = { status: "connecting" };
27649
28986
  this.observeTransport(linkId);
27650
28987
  this.emitState();
@@ -27697,7 +29034,8 @@ var GhostlyNode = class GhostlyNode {
27697
29034
  details: GhostlyNode.receivedTextDetails(!!stored.profile, message),
27698
29035
  ...message.preview && { preview: message.preview },
27699
29036
  ...stored.profile && message.reply && { replyTo: receivedPairedReply(message.reply) },
27700
- ...stored.profile && message.forwarded && { forwarded: message.forwarded }
29037
+ ...stored.profile && message.forwarded && { forwarded: message.forwarded },
29038
+ ...stored.profile && message.card && { card: message.card }
27701
29039
  });
27702
29040
  },
27703
29041
  onCallSignal: (signal) => this.events.onCallSignal(linkId, signal),
@@ -27720,7 +29058,10 @@ var GhostlyNode = class GhostlyNode {
27720
29058
  onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
27721
29059
  onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
27722
29060
  onPayment: (payment) => this.desk.onPayment(linkId, payment),
27723
- onPaymentResult: (result) => this.desk.onPaymentResult(linkId, result),
29061
+ onPaymentResult: async (result) => {
29062
+ await this.desk.onPaymentResult(linkId, result);
29063
+ this.notePaymentReceipt(linkId, result.id).catch(() => {});
29064
+ },
27724
29065
  onFileStored: async (wire) => {
27725
29066
  const stored = (await fileStore.listForLink(linkId)).find((file) => file.direction === "in" && file.wireId === wire.id);
27726
29067
  if (!stored?.digest || !stored.metadata) return void 0;
@@ -27738,8 +29079,14 @@ var GhostlyNode = class GhostlyNode {
27738
29079
  const link = live.link;
27739
29080
  link.start();
27740
29081
  this.emitState();
29082
+ const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? this.transportLogOf(live)?.liveAtLastRun : void 0;
29083
+ if (resumed) {
29084
+ traceLink(live.myPubKeyZ32, "resume", { transport: resumed });
29085
+ this.watchResume(linkId, link);
29086
+ }
29087
+ const nativeUp = stored.deliveryMode !== "dht" && this.keepsNativeEndpoints(linkId, stored) ? this.ensureNativeEndpoints(linkId) : void 0;
27741
29088
  if (credentials && !stored.group) {
27742
- live.caps = new CapsExchange({
29089
+ const caps = live.caps = new CapsExchange({
27743
29090
  params: stored,
27744
29091
  credentials,
27745
29092
  transport: this.transport,
@@ -27755,14 +29102,21 @@ var GhostlyNode = class GhostlyNode {
27755
29102
  changed: (record) => this.peerCapsChanged(linkId, record),
27756
29103
  published: () => live.link?.announceCapsRevision()
27757
29104
  });
27758
- live.caps.start();
27759
- const peer = live.caps.peer;
29105
+ if (stored.pairedPeerKey && nativeUp) {
29106
+ const late = setTimeout(() => caps.start(), STARTUP_QUIET_MS);
29107
+ const go = () => {
29108
+ clearTimeout(late);
29109
+ caps.start();
29110
+ };
29111
+ nativeUp.then(go, go);
29112
+ } else caps.start();
29113
+ const peer = caps.peer;
27760
29114
  if (peer) {
27761
29115
  link.learnPeerTransports(peer.transports.filter((t) => TRANSPORTS.includes(t)), dialDescriptors(peer.descriptors));
27762
29116
  link.learnPeerChoice(peer.choice);
27763
29117
  }
27764
29118
  }
27765
- if (stored.deliveryMode !== "dht" && (stored.pairedPeerKey || this.activeLinkId === linkId)) this.ensureNativeEndpoints(linkId).then(() => {
29119
+ nativeUp?.then(() => {
27766
29120
  if (live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
27767
29121
  });
27768
29122
  }
@@ -27837,7 +29191,7 @@ var GhostlyNode = class GhostlyNode {
27837
29191
  get nativeFactories() {
27838
29192
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
27839
29193
  return {
27840
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-BKsFpPjr.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
29194
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-_rJsllcP.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
27841
29195
  ...this.options.nativeTransports,
27842
29196
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
27843
29197
  };
@@ -27864,6 +29218,15 @@ var GhostlyNode = class GhostlyNode {
27864
29218
  return !this.options.nativeTransports?.["hyperdht/1"];
27865
29219
  }
27866
29220
  /**
29221
+ * Whether a chat keeps native listeners: a contact's, the chat on screen, and one this side joined from a `ghostly1`
29222
+ * invite that is still pairing. The joiner dials, and without an endpoint of its own an app with no WebRTC (the Linux
29223
+ * Desktop) offered nothing the inviter could take: someone who went back to the chat list right after joining waited
29224
+ * out the connect (90 s) before the chat went live. An invite of this side's that nobody used yet keeps none.
29225
+ */
29226
+ keepsNativeEndpoints(linkId, stored) {
29227
+ return !!stored.pairedPeerKey || !!stored.peerParticipationKeyZ32 || this.activeLinkId === linkId;
29228
+ }
29229
+ /**
27867
29230
  * A relayed endpoint went away (the relay restarted, the network dropped): try again in a while, for every
27868
29231
  * chat that keeps native listeners, as opening a chat would.
27869
29232
  */
@@ -27872,7 +29235,7 @@ var GhostlyNode = class GhostlyNode {
27872
29235
  if (!this.relaysHyperdht || !this.hyperdhtRelay || this.relayRetry || this.shuttingDown) return;
27873
29236
  this.relayRetry = setTimeout(() => {
27874
29237
  this.relayRetry = null;
27875
- for (const [linkId, live] of this.links) if (live.link && live.stored.profile && !live.stored.group && live.stored.deliveryMode !== "dht" && !live.link.availableTransports.includes("hyperdht/1") && (live.stored.pairedPeerKey || this.activeLinkId === linkId)) this.ensureNativeEndpoints(linkId);
29238
+ for (const [linkId, live] of this.links) if (live.link && live.stored.profile && (!live.stored.group || this.keepsGroupNative(live.stored)) && live.stored.deliveryMode !== "dht" && !live.link.availableTransports.includes("hyperdht/1") && this.keepsNativeEndpoints(linkId, live.stored)) this.ensureNativeEndpoints(linkId);
27876
29239
  }, RELAY_RETRY_MS);
27877
29240
  }
27878
29241
  /** A new relay (or none): chats give up their endpoints on the old one, as soon as none of them carries a session. */
@@ -27881,7 +29244,7 @@ var GhostlyNode = class GhostlyNode {
27881
29244
  for (const [linkId, live] of this.links) {
27882
29245
  const link = live.link;
27883
29246
  if (!link?.availableTransports.includes("hyperdht/1")) {
27884
- if (this.hyperdhtRelay && link && (live.stored.pairedPeerKey || this.activeLinkId === linkId)) this.ensureNativeEndpoints(linkId);
29247
+ if (this.hyperdhtRelay && link && this.keepsNativeEndpoints(linkId, live.stored)) this.ensureNativeEndpoints(linkId);
27885
29248
  continue;
27886
29249
  }
27887
29250
  if (!link.canReleaseEndpoint("hyperdht/1")) continue;
@@ -27895,19 +29258,34 @@ var GhostlyNode = class GhostlyNode {
27895
29258
  const expected = this.links.get(linkId)?.link;
27896
29259
  const operation = this.nativeQueue.then(async () => {
27897
29260
  const live = this.links.get(linkId), link = live?.link;
27898
- if (this.shuttingDown || !live?.stored.profile || live.stored.group || live.stored.deliveryMode === "dht" || !link || link !== expected) return;
29261
+ if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return;
29262
+ const group = !!live.stored.group;
29263
+ if (group && !this.keepsGroupNative(live.stored)) return;
29264
+ let full = false;
27899
29265
  for (const [transport, factory] of Object.entries(this.nativeFactories)) {
27900
29266
  const key = transport;
27901
29267
  if (!factory || link.availableTransports.includes(key)) continue;
27902
29268
  live.transportErrors ??= {};
27903
29269
  try {
27904
29270
  const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
27905
- if (owners.length >= 8) {
27906
- const victim = this.activeLinkId === linkId ? owners.filter((other) => other !== live && other.stored.id !== this.activeLinkId && other.link?.canReleaseEndpoint(key)).sort((a, b) => a.lastMessageAt - b.lastMessageAt)[0] : void 0;
27907
- if (!victim) throw new Error("All eight native connection slots are in use. Disconnect a native connection in another chat, then reopen this chat or press Reconnect.");
29271
+ if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) {
29272
+ full = true;
29273
+ continue;
29274
+ }
29275
+ if (owners.length >= NATIVE_SLOTS) {
29276
+ const victim = owners.filter((other) => other.stored.group && other.link?.canReleaseEndpoint(key)).sort((a, b) => (a.link?.isDataLinkOpen ? 1 : 0) - (b.link?.isDataLinkOpen ? 1 : 0))[0] ?? (this.activeLinkId === linkId ? owners.filter((other) => other !== live && other.stored.id !== this.activeLinkId && other.link?.canReleaseEndpoint(key)).sort((a, b) => a.lastMessageAt - b.lastMessageAt)[0] : void 0);
29277
+ if (!victim) {
29278
+ traceLink(live.myPubKeyZ32, "native-no-slot", {
29279
+ transport: key,
29280
+ active: this.activeLinkId === linkId
29281
+ });
29282
+ if (this.activeLinkId === linkId && !group) this.retryActiveSlot(linkId);
29283
+ throw new Error("All eight native connection slots are in use. Disconnect a native connection in another chat, then reopen this chat or press Reconnect.");
29284
+ }
27908
29285
  await victim.link.releaseEndpoint(key);
27909
29286
  victim.transportErrors ??= {};
27910
29287
  victim.transportErrors[key] = "Listener released for another chat. Open this chat to restore it; your messages and transport identity are saved.";
29288
+ if (victim.stored.group) this.groupNativeWaiting.add(victim.stored.id);
27911
29289
  }
27912
29290
  if (this.shuttingDown || live.link !== link) return;
27913
29291
  const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
@@ -27932,12 +29310,95 @@ var GhostlyNode = class GhostlyNode {
27932
29310
  live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
27933
29311
  }
27934
29312
  }
29313
+ if (group) {
29314
+ if (full && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
29315
+ else this.groupNativeWaiting.delete(linkId);
29316
+ }
27935
29317
  this.emitState();
27936
29318
  });
27937
29319
  this.nativeQueue = operation.catch(() => {});
27938
29320
  return operation;
27939
29321
  }
27940
29322
  /**
29323
+ * The order saved chats start in, which is the order they take native listeners in (`NATIVE_SLOTS` per transport):
29324
+ * the 1:1 chats written in, or live, most recently first; group links keep their places. In the order they were
29325
+ * stored, an app with more paired chats than slots left a chat that was live a moment before with no listener, and a
29326
+ * contact with no WebRTC (the Linux Desktop) never reached it again (Omarchy, 2026-09-30: "On DHT · retrying live" for
29327
+ * minutes after both apps restarted). Not "live when this app last ran" first: a chat that heard its contact leave as
29328
+ * both quit is not.
29329
+ *
29330
+ * On an app with WebRTC, the chats whose contact has none (its capability record lists no `webrtc/1`: a Linux Desktop,
29331
+ * a CLI with WebRTC off) come first, each class in that order: they reach this app over a native transport or not
29332
+ * live at all, while a contact with WebRTC still has it when no listener is left here.
29333
+ */
29334
+ nativeStartOrder(linkIds) {
29335
+ const isChat = (id) => {
29336
+ const stored = this.links.get(id)?.stored;
29337
+ return !!stored && !stored.group;
29338
+ };
29339
+ const rtc = typeof RTCPeerConnection !== "undefined";
29340
+ const rank = new Map(linkIds.filter(isChat).map((id) => {
29341
+ const live = this.links.get(id);
29342
+ const theirs = live.stored.capsState?.peer?.transports ?? live.stored.peerTransports;
29343
+ return [id, {
29344
+ recent: Math.max(live.lastMessageAt, this.transportLogOf(live)?.lastLiveAt ?? 0),
29345
+ nativeOnly: rtc && !!theirs && !theirs.includes("webrtc/1")
29346
+ }];
29347
+ }));
29348
+ const chats = [...rank.keys()].sort((a, b) => {
29349
+ const x = rank.get(a), y = rank.get(b);
29350
+ return Number(y.nativeOnly) - Number(x.nativeOnly) || y.recent - x.recent;
29351
+ });
29352
+ chats.forEach((id, place) => traceLink(this.links.get(id).myPubKeyZ32, "native-order", {
29353
+ place,
29354
+ ...rank.get(id)
29355
+ }));
29356
+ let next = 0;
29357
+ return linkIds.map((id) => rank.has(id) ? chats[next++] : id);
29358
+ }
29359
+ /**
29360
+ * A chat started as "live when this app last ran" (it resumes, and knocks): if this run is not live in it within
29361
+ * `RESUME_SPENT_MS`, its history says that stretch is over, so the next start does not resume it again.
29362
+ */
29363
+ resumeTimers = /* @__PURE__ */ new Map();
29364
+ watchResume(linkId, link) {
29365
+ const before = this.resumeTimers.get(linkId);
29366
+ if (before) clearTimeout(before);
29367
+ this.resumeTimers.set(linkId, setTimeout(() => {
29368
+ this.resumeTimers.delete(linkId);
29369
+ const live = this.links.get(linkId), log = live && this.transportLogOf(live);
29370
+ if (this.shuttingDown || !live || !log || live.link !== link) return;
29371
+ if (!log.notBackAfterRestart(Date.now())) return;
29372
+ traceLink(live.myPubKeyZ32, "resume-spent", {});
29373
+ this.saveTransportLog(live, log);
29374
+ }, RESUME_SPENT_MS));
29375
+ }
29376
+ /** The chat on screen found every native slot busy: it tries again in a moment, while it stays on screen. */
29377
+ activeSlotRetry = null;
29378
+ retryActiveSlot(linkId) {
29379
+ if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
29380
+ if (this.shuttingDown) return;
29381
+ this.activeSlotRetry = setTimeout(() => {
29382
+ this.activeSlotRetry = null;
29383
+ if (this.activeLinkId === linkId && !this.shuttingDown) this.ensureNativeEndpoints(linkId);
29384
+ }, ACTIVE_SLOT_RETRY_MS);
29385
+ }
29386
+ /** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
29387
+ groupNativeWaiting = /* @__PURE__ */ new Set();
29388
+ groupNativeRetryAt = 0;
29389
+ /** Now and then, the group links that found no free native slot try again: a chat or another group may have let one go. */
29390
+ retryGroupNative() {
29391
+ if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
29392
+ this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
29393
+ for (const linkId of [...this.groupNativeWaiting]) {
29394
+ if (!this.links.get(linkId)?.link) {
29395
+ this.groupNativeWaiting.delete(linkId);
29396
+ continue;
29397
+ }
29398
+ this.ensureNativeEndpoints(linkId);
29399
+ }
29400
+ }
29401
+ /**
27941
29402
  * A group message first stored from a copy a member handed on without its author's whole signature: what the whole
27942
29403
  * copy adds (mentions, a reply, a hop count) joins the stored row; the text and time stay as they were.
27943
29404
  */
@@ -27948,10 +29409,16 @@ var GhostlyNode = class GhostlyNode {
27948
29409
  ...whole.mentions && { mentions: whole.mentions },
27949
29410
  ...whole.mentioned && { mentioned: true },
27950
29411
  ...whole.replyTo && { replyTo: whole.replyTo },
27951
- ...whole.forwarded && { forwarded: whole.forwarded }
29412
+ ...whole.forwarded && { forwarded: whole.forwarded },
29413
+ ...whole.card && { card: whole.card },
29414
+ ...whole.press && { press: whole.press }
27952
29415
  };
27953
29416
  if (!Object.keys(added).length) return;
27954
- if (!await db.patchMessage(message.linkId, message.id, (stored) => stored.member === message.member && stored.sender === message.sender ? added : null)) return;
29417
+ if (!await db.patchMessage(message.linkId, message.id, (stored) => {
29418
+ if (stored.member !== message.member || stored.sender !== message.sender) return null;
29419
+ const { card: _card, ...rest } = added;
29420
+ return stored.edit ? rest : added;
29421
+ })) return;
27955
29422
  await this.messagesChanged(message.linkId, [message.id]);
27956
29423
  this.emitState();
27957
29424
  }
@@ -27960,6 +29427,15 @@ var GhostlyNode = class GhostlyNode {
27960
29427
  * the whole history. A host that takes whole histories only (no `onMessageChanges`) gets the whole history, as before.
27961
29428
  */
27962
29429
  async messagesChanged(linkId, ids) {
29430
+ const reacted = this.reactionNotes.get(linkId);
29431
+ if (reacted?.message && ids.includes(reacted.message)) {
29432
+ const row = await db.getMessage(linkId, reacted.message);
29433
+ const fresh = row && noteAfterChange(reacted, row);
29434
+ if (fresh) {
29435
+ this.reactionNotes.set(linkId, fresh);
29436
+ this.emitState();
29437
+ }
29438
+ }
27963
29439
  const onChanges = this.events.onMessageChanges;
27964
29440
  if (!onChanges) return this.events.onMessages(linkId, await db.getMessages(linkId));
27965
29441
  const unique = [...new Set(ids)];
@@ -27971,6 +29447,10 @@ var GhostlyNode = class GhostlyNode {
27971
29447
  });
27972
29448
  }
27973
29449
  async storeMessage(message) {
29450
+ await this.storeNewMessage(message);
29451
+ }
29452
+ /** Stores a message; false when it was there already (or deleted here): nothing new came. */
29453
+ async storeNewMessage(message) {
27974
29454
  if (message.sender === "peer") message = {
27975
29455
  ...message,
27976
29456
  timestamp: receivedTimestamp(message.timestamp)
@@ -27990,7 +29470,7 @@ var GhostlyNode = class GhostlyNode {
27990
29470
  ...message.sender === "peer" ? { member: pay.member } : {}
27991
29471
  };
27992
29472
  const live = this.links.get(message.linkId);
27993
- if (live?.stored.deletedIds?.includes(message.id)) return;
29473
+ if (live?.stored.deletedIds?.includes(message.id)) return false;
27994
29474
  message = await this.resolveReply(message);
27995
29475
  if (message.sender === "peer" && live && !message.details?.received) message = {
27996
29476
  ...message,
@@ -28016,7 +29496,7 @@ var GhostlyNode = class GhostlyNode {
28016
29496
  }
28017
29497
  };
28018
29498
  }
28019
- if (!await db.addMessage(message)) return;
29499
+ if (!await db.addMessage(message)) return false;
28020
29500
  const newest = this.newestAt.get(message.linkId);
28021
29501
  if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, message.timestamp));
28022
29502
  if (message.sender === "peer") this.messageFeedback("message", message, newest);
@@ -28030,6 +29510,7 @@ var GhostlyNode = class GhostlyNode {
28030
29510
  this.emitState();
28031
29511
  await this.reactions.stored(message);
28032
29512
  await this.groupEdits.stored(message);
29513
+ return true;
28033
29514
  }
28034
29515
  servicesChanged() {
28035
29516
  for (const live of this.links.values()) live.link?.refreshServices();
@@ -28071,6 +29552,7 @@ var GhostlyNode = class GhostlyNode {
28071
29552
  peerProofs: void 0,
28072
29553
  proofError: void 0,
28073
29554
  availableTransports: live.link?.availableTransports,
29555
+ runnableTransports: live.link ? this.runnableTransports(live) : void 0,
28074
29556
  relayedTransports: live.link?.relayedTransports,
28075
29557
  deliveryMode: live.stored.deliveryMode ?? "stream",
28076
29558
  dhtDelivery: live.link?.dhtDelivery,
@@ -28139,6 +29621,14 @@ var GhostlyNode = class GhostlyNode {
28139
29621
  this.events.onState(this.getState());
28140
29622
  }, delayMs);
28141
29623
  }
29624
+ /** The state now, not after `emitState`'s wait: for what the app must have before what follows it. */
29625
+ flushState() {
29626
+ if (this.stateTimer) {
29627
+ clearTimeout(this.stateTimer);
29628
+ this.stateTimer = null;
29629
+ }
29630
+ this.events.onState(this.getState());
29631
+ }
28142
29632
  };
28143
29633
  //#endregion
28144
29634
  //#region ../browser/src/engine/server.ts
@@ -28154,6 +29644,11 @@ var EngineServer = class {
28154
29644
  histories = /* @__PURE__ */ new WeakMap();
28155
29645
  /** Per chat or group, what is still being sent (a whole history being read): what comes next waits for it. */
28156
29646
  sending = /* @__PURE__ */ new Map();
29647
+ /**
29648
+ * Per chat, the contact's call offer that nothing here has answered yet. A client that attaches while it rings hears
29649
+ * it too: the extension's peer runs with no tab open, and a tab opened during the ring used to show no call.
29650
+ */
29651
+ offers = /* @__PURE__ */ new Map();
28157
29652
  constructor(options = {}) {
28158
29653
  this.node = new GhostlyNode({
28159
29654
  onAttention: (event) => this.broadcast({
@@ -28166,11 +29661,15 @@ var EngineServer = class {
28166
29661
  }),
28167
29662
  onMessages: (linkId, messages) => void this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {}),
28168
29663
  onMessageChanges: (linkId, changes) => void this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {}),
28169
- onCallSignal: (linkId, signal) => this.broadcast({
28170
- kind: "call-signal",
28171
- linkId,
28172
- signal
28173
- })
29664
+ onCallSignal: (linkId, signal) => {
29665
+ if (signalKind(signal) === "o") this.offers.set(linkId, signal);
29666
+ else this.offers.delete(linkId);
29667
+ this.broadcast({
29668
+ kind: "call-signal",
29669
+ linkId,
29670
+ signal
29671
+ });
29672
+ }
28174
29673
  }, options);
28175
29674
  this.ready = this.node.start();
28176
29675
  }
@@ -28183,6 +29682,12 @@ var EngineServer = class {
28183
29682
  kind: "state",
28184
29683
  state: this.node.getState()
28185
29684
  });
29685
+ for (const [linkId, signal] of this.offers) if (parseCallSignal(signal)?.t === "o") client.post({
29686
+ kind: "call-signal",
29687
+ linkId,
29688
+ signal
29689
+ });
29690
+ else this.offers.delete(linkId);
28186
29691
  for (const link of this.node.getState().links) await this.inOrder(link.id, async () => {
28187
29692
  if (!this.clients.has(client)) return;
28188
29693
  client.post({
@@ -28207,6 +29712,7 @@ var EngineServer = class {
28207
29712
  kind: "response",
28208
29713
  id: request.id
28209
29714
  };
29715
+ if (request.method === "setCallSignal") this.offers.delete(request.params?.linkId ?? "");
28210
29716
  try {
28211
29717
  await this.ready;
28212
29718
  const method = this.node[request.method];
@@ -28287,7 +29793,15 @@ var EngineServer = class {
28287
29793
  for (const client of [...this.clients]) this.post(client, event);
28288
29794
  }
28289
29795
  };
29796
+ /** A call signal's kind (`o` for an offer), read as the client reads it; checked in full before it is replayed. */
29797
+ function signalKind(signal) {
29798
+ try {
29799
+ return JSON.parse(signal)?.t;
29800
+ } catch {
29801
+ return;
29802
+ }
29803
+ }
28290
29804
  //#endregion
28291
29805
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
28292
29806
 
28293
- //# sourceMappingURL=server-5T5l8hfy.js.map
29807
+ //# sourceMappingURL=server-C-8wbsAX.js.map