@ghostlytools/cli 1.0.1 → 1.0.3

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 (29) hide show
  1. package/README.md +10 -7
  2. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  3. package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
  4. package/dist/assets/fileBytes-Br_MpeNT.js +123 -0
  5. package/dist/assets/fileBytes-Br_MpeNT.js.map +1 -0
  6. package/dist/assets/{fileBytes-CLv0gryb.js → fileBytes-CpP9Vj7s.js} +2 -2
  7. package/dist/assets/{fileBytes-CLv0gryb.js.map → fileBytes-CpP9Vj7s.js.map} +1 -1
  8. package/dist/assets/{fileBytesIdb-CKCtzMTy.js → fileBytesIdb-vubSXPoO.js} +3 -2
  9. package/dist/assets/{fileBytesIdb-CKCtzMTy.js.map → fileBytesIdb-vubSXPoO.js.map} +1 -1
  10. package/dist/assets/{fileBytesOpfs-By55XVTI.js → fileBytesOpfs-6iSa6u2K.js} +20 -8
  11. package/dist/assets/fileBytesOpfs-6iSa6u2K.js.map +1 -0
  12. package/dist/assets/{hyperdhtRelay-_rJsllcP.js → hyperdhtRelay-VM9STKLd.js} +3 -3
  13. package/dist/assets/{hyperdhtRelay-_rJsllcP.js.map → hyperdhtRelay-VM9STKLd.js.map} +1 -1
  14. package/dist/assets/idb-DIN_Miwb.js +351 -0
  15. package/dist/assets/idb-DIN_Miwb.js.map +1 -0
  16. package/dist/assets/{mainline-B0mUxYPH.js → mainline-CcO2f8jy.js} +17 -10
  17. package/dist/assets/mainline-CcO2f8jy.js.map +1 -0
  18. package/dist/assets/restoredRows-tVxgyG23.js +79 -0
  19. package/dist/assets/restoredRows-tVxgyG23.js.map +1 -0
  20. package/dist/assets/{server-C-8wbsAX.js → server-BnNXYzR9.js} +2371 -358
  21. package/dist/assets/server-BnNXYzR9.js.map +1 -0
  22. package/dist/assets/src-7sdQpCoi.js +23665 -0
  23. package/dist/assets/src-7sdQpCoi.js.map +1 -0
  24. package/dist/ghostly.mjs +933 -23260
  25. package/dist/ghostly.mjs.map +1 -1
  26. package/package.json +2 -2
  27. package/dist/assets/fileBytesOpfs-By55XVTI.js.map +0 -1
  28. package/dist/assets/mainline-B0mUxYPH.js.map +0 -1
  29. package/dist/assets/server-C-8wbsAX.js.map +0 -1
@@ -1,4 +1,7 @@
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";
1
+ import { a as fileBytesOf, c as removeFileBytes, d as __exportAll, i as fileBytes, n as blobDigest } from "./fileBytes-Br_MpeNT.js";
2
+ import { $a as createIdentity, $i as heardTime, $n as newerHead, $r as revokesIdentity, $t as isBitcoinAddress, Aa as LEGACY_SERVICES, Ai as GROUP_REACTION_FRAME, An as cashuRequestPayload, Ar as EDIT_CAPABILITY, At as didDhtFromPublicKey, Ba as currentRelays, Bi as pairedReplyAuthor, Bn as MESH_HUBS, Br as capsDescriptors, Bt as SEPOLIA_TEST_USDT_FAUCET, C as groupWakeFrame, Ci as EDIT_RATE_WINDOW_MS, Cr as WakeLimiter, D as GROUP_READ_NOTE, Di as forwardedAgain, Dn as engineText, Dr as checkPushEndpoint, Ea as parseCallSignal, Ei as parseLinkPreview, En as engineError, Er as wakeRequest, F as mentionsMember, Fa as encodeInviteCode, Fi as reactionEmoji, Fn as parseFedimintRequestPayload, Fr as HoldRefusedError, Ft as ETHEREUM_USDT, Ga as isDiscoveryBudgetError, Gn as beaconKeys, Gt as assertWholeSats, Hi as replySnippet, Hn as meshHubs, Ht as USDT_PUBLIC_RPC, I as groupName, Ii as reactionIsNewer, In as GhostLink, Ir as newMailbox, It as EVM_TEST_CHAINS, Ji as checkStatusCard, Jn as freshHubs, Jr as emptyIdentityLedger, Jt as walletNetworkOf, Ka as withRequestOptions, Ki as STATUS_CARD_LIMITS, Kn as beaconRecords, Kr as IdentityExchange, Kt as formatPaymentAmount, La as inviteOwnership, Li as readReaction, Ln as INVITE_TAKEN, Lr as readManifest, Lt as ONCHAIN_PROVIDER, M as GROUP_EDIT_TEXT_BYTES, Ma as OwnInviteError, Mi as ReactionWindow, Mn as findEndpoint, Mr as HOLD_DATED_BACK_MS, Mt as encodeDidDhtPacket, N as carryMentions, Na as createChatInvite, Ni as nextReactionNumber, Nn as isFederationId, Nr as HOLD_LIMITS, O as GroupSession, Oa as emptyLinkRecords, Oi as readForwarded, On as decodeBolt11, Or as vapidKeysMatch, P as parseCommunityEdit, Pa as decodeInviteCode, Pi as queueReaction, Pn as parseCashuRequestPayload, Pr as HoldKeys, Pt as signDidDhtPacket, Qi as claimedTime, Qn as mergeLobby, Qr as receivedIdentityStatus, Qt as sparkInvoiceDetails, Ra as DEFAULT_RELAYS, Ri as validReactionNumber, Rn as LIVENESS_PING_MS, Rr as CapsExchange, Rt as PaymentPreflightError, S as GroupWakeLimiter, Si as EDIT_BUFFER_MS, Sr as WAKE_CALL_INTERVAL_MS, T as parseGroupWakeFrame, Ti as dhtEditId, Tr as relayRequest, Ua as traceLink, Ui as BUTTON_ID, Un as pickMeshHubs, Ut as WALLET_NETWORKS, Va as normalizeRelayUrl, Vi as readReply, Vn as mayBeHub, Vr as dialDescriptors, Vt as TEST_USDT_FAUCET_AMOUNT, Wa as budgetRetryMs, Wn as COMMUNITY_TOPOLOGY, Wr as IDENTITY_REVOCATION_LABEL, Wt as assertTokenUnits, Xi as statusCardText, Xn as lobbyRecords, Xr as identityStatement, Xt as SPARK_PROVIDER, Ya as createRelayPayload, Yi as sameButtonText, Yn as lobbyKeys, Yr as identityRevocationValue, Yt as SPARK_NETWORKS, Zi as arrivalKey, Zn as mergeBeacon, Zr as newIdentityBinding, Zt as sparkAddressKind, _ as knockIdentity, _a as decodeControl, _i as parseVoiceMeta, _r as mayPin, a as sealGroupTyping, aa as rosterHas, ai as automaticTransport, ao as bytesEqual, ar as shouldBeHub, b as readKnocks, ba as RTC_CONFIG, bi as DHT_MESSAGE_TTL, br as pinNumberHolds, c as GROUP_READ_NOTE_COMMUNITY, ca as DirectPathWatch, ci as parseImageMeta, co as toBase64Url, cr as transferEnded, d as decodeCommunityLink, da as presenceSeenAt, do as utf8Encode, ea as receivedTimestamp, ei as lastSharedWithMe, eo as identityFromSeed, er as pickHubs, f as encodeCommunityLink, fa as GhostlyHttpError, fr as TypingSender, g as entryParams, ga as LIMITS, gi as fileMessageText, gr as PIN_LIMITS, h as encodeGroupEntryLink, ha as webLocalFetch, i as openGroupTyping, ia as MEMBER_KEY, ii as TRANSPORTS, io as sanitizeAvatar, ir as readLobby, ja as serviceIdFromName, ji as REACTION_LIMITS, jn as fedimintRequestPayload, jr as HOLD_CAPABILITY, jt as encodeDidDhtDeactivated, k as groupMessageId, ka as HTTP_SERVICE_PROTO, ki as COMMUNITY_REACTION_FRAME, kn as ENDPOINT, kt as didDhtDocument, l as communityHasFrame, lo as toZ32, lr as transferStalled, m as decodeGroupEntryLink, ma as parseLocalTarget, mr as typingActivity, n as GroupTypingBoard, na as GROUP_MEMBER_CAP, ni as emptyProofLedger, no as publicKeyFromZ32, nr as readBeacon, nt as isAtprotoDid, o as COMMUNITY_LIMITS, oa as verifyCommitSignature, oi as irohDescriptor, oo as fromBase64Url, or as ChatFiles, pa as formatLocalTarget, qa as PacketTooLargeError, qn as doorHubs, qt as validatePaymentTarget, r as groupTypingEpoch, ri as validExternalKey, rr as readBeaconHead, rt as normalizeAtprotoHandle, s as CommunitySession, sa as edgeParams, si as irohRelayUrl, so as randomBytes, sr as FILE_LIMITS, ta as shownTime, ti as PeerProofs, tn as isPubkyKey, to as identityFromSeedB64, tr as rankHubs, u as communityMessageId, ua as RELAY_POLL_INTERVALS, ui as safeBlobType, uo as utf8Decode, v as knockRecords, va as encodeControl, vr as nextPinNumber, w as groupWakes, wi as RateWindow, wr as newWakeToken, xi as peekDhtMailbox, xr as readPin, y as mergeKnocks, yi as parseVideoMeta, yr as pinIsNewer, za as RelayTransport, zi as wireReaction, zn as pairedMessageFrame, zr as DHT_TEXT_CAPABILITY, zt as SEPOLIA_TEST_USDT } from "./src-7sdQpCoi.js";
3
+ import { a as deleteDatabase, c as profileOpenFailure, d as wrap, i as databaseName, l as store, n as STORES, o as fileStore, r as clearProfileStores, s as openDb, t as CARD_INDEX, u as transact } from "./idb-DIN_Miwb.js";
4
+ import { $ as boundedIdentityFetch, A as TEST_MINT, B as registeredLightningProviders, C as networkLabel, D as WALLET_TYPES, F as paysItsOwnInvoices, G as SourceConfigError, H as reserveAdapterIds, I as BACKUP_MEDIA_TYPE, J as networkMode, K as SourceUnreachableError, L as MAX_BACKUP_BYTES, M as isTestMint, N as isWorthlessMint, O as BITCOIN_INVOICE_ON_TESTNET, P as mintNetwork, Q as setOwnDidSource, R as identityProviders, S as WrongNetworkError, T as cashuMint, U as NothingSpentError, V as registeredOnchainProviders, W as PROVIDER_PLATFORMS, X as assertPublicServer, Y as redact, Z as resolveAtprotoDid, _ as newDeviceKey, a as createFailure, at as replyRef, b as ModeChanged, c as paymentNetwork, ct as paymentWireId, d as networkReason, dt as removeStored, et as verifyIdentity, f as rpcHost, ft as storedSize, g as intentRepository, h as ProviderSources, i as assertConfirmedReal, it as receivedPairedReply, j as fakesLightning, k as DEFAULT_MINTS, l as paymentNetworksOf, lt as FileAppender, m as LightningCards, n as loadFedimintSdk, nt as groupReply, o as createTiming, ot as replyTo, p as CASHU_CARD, pt as streamStored, q as isNothingSpentError, r as WALLET_NAMES, rt as pairedWireReply, s as crossNetwork, st as paymentAlias, t as fedimintDatabase, tt as chosenResolver, u as walletInstances, ut as readStored, v as sealSeed, w as CASHU_MINT_SOURCE, x as ModeGate, y as unsealSeed, z as onAdaptersChanged } from "../ghostly.mjs";
2
5
  import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
3
6
  import { hkdf } from "@noble/hashes/hkdf.js";
4
7
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -12,11 +15,112 @@ import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
12
15
  import { Interface, Transaction, getAddress, keccak256 } from "ethers";
13
16
  import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
14
17
  import { tapLeafHash } from "@scure/btc-signer/payment.js";
18
+ import { Amount, MeltChangeError, OutputData, Wallet as Wallet$1, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
15
19
  import { Transaction as Transaction$2 } from "@scure/btc-signer";
16
20
  import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
17
21
  import * as nip04 from "nostr-tools/nip04";
18
22
  import * as nip44 from "nostr-tools/nip44";
19
- import { Amount, MeltChangeError, OutputData, Wallet as Wallet$1, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
23
+ /** Evidence older than this says nothing about the clock now. */
24
+ var CLOCK_EVIDENCE_MS = 18e5;
25
+ /** Sources kept of each kind: a profile has a few relays, and may have hundreds of contacts. */
26
+ var KEPT = 64;
27
+ /** -1: everything the sample allows is behind by more than `CLOCK_OFF_MS`; 1: ahead; 0: it does not say. */
28
+ var says = (sample) => sample.low > 6e4 ? 1 : sample.high < -6e4 ? -1 : 0;
29
+ var middle = (values) => {
30
+ const sorted = [...values].sort((a, b) => a - b);
31
+ return sorted[Math.floor((sorted.length - 1) / 2)];
32
+ };
33
+ /**
34
+ * The rule alone: how far this clock is from the others' (ms, positive when it is ahead) when the samples fresh at
35
+ * `now` say it is clearly off, else null.
36
+ */
37
+ function clockOffset(servers, peers, now) {
38
+ const fresh = (samples) => [...samples.values()].filter((s) => now - s.at < CLOCK_EVIDENCE_MS);
39
+ const s = fresh(servers), p = fresh(peers);
40
+ const mid = (sample) => (sample.low + sample.high) / 2;
41
+ for (const way of [1, -1]) {
42
+ const serversOff = s.filter((x) => says(x) === way), peersOff = p.filter((x) => says(x) === way);
43
+ const serversNot = s.length - serversOff.length, peersNot = p.length - peersOff.length;
44
+ if (serversOff.length >= 2 && serversNot === 0) return Math.round(middle(serversOff.map(mid)));
45
+ if (serversNot > 0) continue;
46
+ if (serversOff.length === 1 && peersOff.length >= 1 && peersNot === 0) return Math.round(middle([...serversOff, ...peersOff].map(mid)));
47
+ if (peersOff.length >= 3 && peersOff.length >= 3 * peersNot) return Math.round(middle(peersOff.map(mid)));
48
+ }
49
+ return null;
50
+ }
51
+ var ClockWatch = class {
52
+ onChange;
53
+ now;
54
+ servers = /* @__PURE__ */ new Map();
55
+ peers = /* @__PURE__ */ new Map();
56
+ was = null;
57
+ timer = null;
58
+ /** `onChange`: what `offset` says changed (it appeared, went away, or moved by a minute). */
59
+ constructor(onChange = () => {}, now = Date.now) {
60
+ this.onChange = onChange;
61
+ this.now = now;
62
+ }
63
+ /**
64
+ * A server's time: `date` (ms, to the second) from an answer to a request that went out at `sent` and came back at
65
+ * `received`, both by this clock.
66
+ */
67
+ server(source, date, sent, received) {
68
+ if (!Number.isFinite(date) || date <= 0 || received < sent) return;
69
+ this.keep(this.servers, source, {
70
+ low: sent - date - 999,
71
+ high: received - date,
72
+ at: this.now()
73
+ });
74
+ }
75
+ /**
76
+ * A contact's packet, dated `packetAt` by its clock, that came between this device's reads of its record at
77
+ * `readBefore` and `readAt` (this clock). Says nothing when the two reads were far apart.
78
+ */
79
+ peer(peer, packetAt, readBefore, readAt) {
80
+ if (!(readBefore > 0) || readAt < readBefore || readAt - readBefore > 15e3 || !Number.isFinite(packetAt)) return;
81
+ this.keep(this.peers, peer, {
82
+ low: readBefore - packetAt,
83
+ high: readAt - packetAt,
84
+ at: this.now()
85
+ });
86
+ }
87
+ keep(samples, source, sample) {
88
+ samples.delete(source);
89
+ samples.set(source, sample);
90
+ if (samples.size > KEPT) samples.delete(samples.keys().next().value);
91
+ this.settle();
92
+ }
93
+ /** This clock minus the others', in ms (positive: ahead), when it is clearly off; else null. */
94
+ get offset() {
95
+ return clockOffset(this.servers, this.peers, this.now());
96
+ }
97
+ /** The clock was set, or the evidence is of another time: nothing is known until the next answers. */
98
+ reset() {
99
+ this.servers.clear();
100
+ this.peers.clear();
101
+ this.settle();
102
+ }
103
+ /** Stops the timer that lets the evidence age out. */
104
+ close() {
105
+ if (this.timer) clearTimeout(this.timer);
106
+ this.timer = null;
107
+ }
108
+ settle() {
109
+ this.close();
110
+ for (const samples of [this.servers, this.peers]) for (const [source, sample] of samples) if (this.now() - sample.at >= 18e5) samples.delete(source);
111
+ const offset = this.offset;
112
+ if (offset !== null) {
113
+ const oldest = Math.min(...[...this.servers.values(), ...this.peers.values()].map((s) => s.at));
114
+ this.timer = setTimeout(() => this.settle(), Math.max(1e3, oldest + CLOCK_EVIDENCE_MS - this.now()));
115
+ this.timer.unref?.();
116
+ }
117
+ const reads = (value) => value === null ? null : Math.round(value / 6e4);
118
+ if (reads(offset) === reads(this.was)) return;
119
+ this.was = offset;
120
+ this.onChange(offset);
121
+ }
122
+ };
123
+ //#endregion
20
124
  //#region ../browser/src/engine/paymentAdapters/walletNetworks.ts
21
125
  /** Where the wallet of one network is stored. The same key an older app parks it under, so it finds it too. */
22
126
  var walletKey = (rail, network) => `${rail}-mode-${network}`;
@@ -3960,26 +4064,59 @@ var CashuAdapter = class {
3960
4064
  token = await this.wallet.executeReviewedCashu(review, prepared);
3961
4065
  } catch (error) {
3962
4066
  prepared.attemptEndedAt = Date.now();
4067
+ if (persist && isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
3963
4068
  throw error;
3964
4069
  }
3965
4070
  await persist?.();
3966
4071
  await this.publish(review, token);
3967
- return { settled: await this.wallet.reviewedCashuSpent(prepared) };
4072
+ return await this.outcome(review, prepared) ?? { settled: false };
4073
+ }
4074
+ /**
4075
+ * What the mint and this wallet know of a payment whose token went out: paid once the mint reads its ecash spent,
4076
+ * unless this wallet is the one that spent it, taking the payment back. Asked in that order: ecash taken back while
4077
+ * the mint was being asked reads spent too. Undefined while the contact has not taken it.
4078
+ */
4079
+ async outcome(review, prepared) {
4080
+ const spent = await this.wallet.reviewedCashuSpent(prepared);
4081
+ if (await this.wallet.reviewedCashuTakenBack(review.id)) return {
4082
+ settled: false,
4083
+ failed: true,
4084
+ error: engineText("paymentTakenBack")
4085
+ };
4086
+ return spent ? { settled: true } : void 0;
3968
4087
  }
3969
4088
  /** `persist` saves the intent as it is: required, since the abandon path must be durable before it releases anything. */
3970
4089
  async reconcile(review, prepared, persist) {
3971
4090
  if (prepared.abandoned) return this.abandon(prepared, persist);
3972
- if (await this.wallet.reviewedCashuSpent(prepared)) return { settled: true };
4091
+ if (prepared.spentElsewhere) return this.spentElsewhere(prepared, persist);
4092
+ const known = await this.outcome(review, prepared);
4093
+ if (known) return known;
3973
4094
  const token = prepared.token ?? await this.wallet.recoverReviewedCashu(review, prepared);
3974
4095
  if (token) await this.publish(review, token);
3975
- if (await this.wallet.reviewedCashuSpent(prepared)) return { settled: true };
4096
+ const after = await this.outcome(review, prepared);
4097
+ if (after) return after;
3976
4098
  if (!token && Date.now() >= swapSettledAt(review, prepared) && await this.wallet.reviewedCashuNeverSwapped(prepared)) return this.abandon(prepared, persist);
4099
+ if (!token && await this.wallet.reviewedCashuSpentElsewhere(prepared)) return this.spentElsewhere(prepared, persist);
3977
4100
  return { settled: false };
3978
4101
  }
3979
4102
  /**
3980
4103
  * The mint proved this swap never happened. Marked for good first (a reload repeats only this), then its reserved
3981
4104
  * inputs are released. Nothing sends the saved swap again: approve claims pending reviews only, and reconcile asks.
3982
4105
  */
4106
+ /**
4107
+ * The ecash this payment was to spend is spent, and not by this payment. Marked for good first, then the wallet lets
4108
+ * go of what the mint reads spent, frees the rest, and asks the mint about its other ecash there.
4109
+ */
4110
+ async spentElsewhere(prepared, persist) {
4111
+ prepared.spentElsewhere = true;
4112
+ await persist();
4113
+ await this.wallet.dropReviewedCashu(prepared);
4114
+ return {
4115
+ settled: false,
4116
+ failed: true,
4117
+ error: engineText("reviewedEcashSpent")
4118
+ };
4119
+ }
3983
4120
  async abandon(prepared, persist) {
3984
4121
  prepared.abandoned = true;
3985
4122
  await persist();
@@ -4146,7 +4283,15 @@ var BitcoinService = class {
4146
4283
  failed: true,
4147
4284
  error: "Another transaction spent these coins: this payment can never confirm"
4148
4285
  };
4149
- if (status.state === "missing") await provider.broadcast(prepared).catch(() => {});
4286
+ if (status.state === "missing") {
4287
+ if (!review.submittedAt) return {
4288
+ txid: prepared.txid,
4289
+ settled: false,
4290
+ failed: true,
4291
+ error: "This payment was never approved: nothing was sent"
4292
+ };
4293
+ await provider.broadcast(prepared).catch(() => {});
4294
+ }
4150
4295
  return {
4151
4296
  txid: prepared.txid,
4152
4297
  settled: false,
@@ -11407,6 +11552,7 @@ function composeDetails(message, around) {
11407
11552
  linkId: message.linkId,
11408
11553
  sender: message.sender,
11409
11554
  timestamp: message.timestamp,
11555
+ ...message.sentAt !== void 0 && { sentAt: message.sentAt },
11410
11556
  via: message.via,
11411
11557
  delivery: message.delivery,
11412
11558
  deliveryError: message.deliveryError,
@@ -11530,6 +11676,7 @@ var FileDesk = class {
11530
11676
  if (chat) return chat;
11531
11677
  const made = {
11532
11678
  local: /* @__PURE__ */ new Map(),
11679
+ places: /* @__PURE__ */ new Map(),
11533
11680
  saving: Promise.resolve(),
11534
11681
  lastSaved: /* @__PURE__ */ new Map(),
11535
11682
  files: void 0
@@ -11569,7 +11716,13 @@ var FileDesk = class {
11569
11716
  this.deps.changed();
11570
11717
  }
11571
11718
  handle(linkId, frame) {
11572
- return this.chat(linkId).files.handle(frame);
11719
+ const chat = this.chat(linkId);
11720
+ const id = frame.t === "pf-offer" && typeof frame.id === "string" ? frame.id : void 0;
11721
+ if (id === void 0 || !this.deps.place || chat.local.has(id) || chat.places.has(id)) return chat.files.handle(frame);
11722
+ chat.places.set(id, this.deps.place(linkId));
11723
+ return chat.files.handle(frame).finally(() => {
11724
+ if (!chat.local.has(id)) chat.places.delete(id);
11725
+ });
11573
11726
  }
11574
11727
  /** Whether files/3 is live in this chat, and what the contact said it can take. */
11575
11728
  status(linkId) {
@@ -11579,6 +11732,11 @@ var FileDesk = class {
11579
11732
  peerRoom: files?.peerRoom ?? null
11580
11733
  };
11581
11734
  }
11735
+ /** A files/3 transfer of this chat's under way on its session: offered, moving, or being checked. */
11736
+ moving(linkId) {
11737
+ const files = this.chats.get(linkId)?.files;
11738
+ return !!files?.live && files.records().some((r) => r.state === "offered" || r.state === "active" || r.state === "verifying");
11739
+ }
11582
11740
  /**
11583
11741
  * Offers a file whose record and bytes are stored under its local id (`outgoingFileId`). Offered before (Retry, a
11584
11742
  * restart): an unfinished transfer is taken over and offered again, a failed one sent again; never a second one.
@@ -11792,6 +11950,8 @@ var FileDesk = class {
11792
11950
  ...file.video && { video: file.video },
11793
11951
  ...file.image && { image: file.image }
11794
11952
  };
11953
+ const place = chat.places.get(record.id), sentAt = claimedTime(file.timestamp);
11954
+ chat.places.delete(record.id);
11795
11955
  chat.saving = chat.saving.then(async () => {
11796
11956
  await fileStore.put({
11797
11957
  id,
@@ -11816,9 +11976,12 @@ var FileDesk = class {
11816
11976
  id: `peer_${record.id}`,
11817
11977
  text: fileMessageText(message),
11818
11978
  sender: "peer",
11819
- timestamp: file.timestamp,
11820
11979
  via: "datalink",
11821
11980
  file: message,
11981
+ ...place !== void 0 && sentAt !== void 0 ? {
11982
+ timestamp: place,
11983
+ sentAt
11984
+ } : { timestamp: file.timestamp },
11822
11985
  ...file.reply && { replyTo: receivedPairedReply(file.reply) },
11823
11986
  ...file.forwarded && { forwarded: file.forwarded },
11824
11987
  details: { wire: fileWire("files/3", file.size) }
@@ -12234,7 +12397,7 @@ function takesPeerEdit(message, text) {
12234
12397
  function withEdit(message, edit) {
12235
12398
  const before = message.edit?.history ?? [];
12236
12399
  const history = edit.text === message.text || edit.card && edit.card.kind !== "buttons" ? before : trimHistory([...before, {
12237
- at: message.edit?.at ?? message.timestamp,
12400
+ at: message.edit?.at ?? shownTime(message),
12238
12401
  text: message.text
12239
12402
  }]);
12240
12403
  const next = {
@@ -12281,9 +12444,32 @@ var RESEND_POLICY = {
12281
12444
  /** Queued this long with the contact away, it is handed to store-and-forward when the chat allows it. */
12282
12445
  holdAfterMs: 6e4
12283
12446
  };
12447
+ /** How long one turn of an outbox may hold the next one back: a send that never returns must not stop the chat. */
12448
+ var TURN_MS = 3e4;
12449
+ /**
12450
+ * A text of mine, not handed to store-and-forward, and still to go: queued, or waiting for the chat to carry it. (A
12451
+ * file or a payment request that waits for the live session has no wire id, and its own sender.)
12452
+ */
12453
+ var toGo = (m) => m.sender === "me" && m.via !== "hold" && !!m.wireId && (m.delivery === "queued" || m.delivery === "waiting");
12454
+ /** A file of mine that waits for the live link (its row has no wire id: the transfer has its own). */
12455
+ var waitingFile = (m) => m.sender === "me" && m.via !== "hold" && !m.wireId && !!m.file && m.delivery === "waiting";
12456
+ var oldestFirst = (a, b) => a.timestamp - b.timestamp;
12284
12457
  /** Durable message IDs survive retries. A lost receipt means unknown delivery, never a claim that the peer did
12285
12458
  * not receive the message: the receiver acknowledges a duplicate id without showing it twice, so an unconfirmed
12286
- * message is sent again by itself while the chat can carry it, within `RESEND_POLICY`, then waits for Retry. */
12459
+ * message is sent again by itself while the chat can carry it, within `RESEND_POLICY`, then waits for Retry.
12460
+ *
12461
+ * What a chat's outbox hands its link goes oldest first. The contact's app places each message where it arrives
12462
+ * (WISP 400, requirement 10), so the order they are sent in is the order the contact reads them in, for good. A new
12463
+ * message, a link that came back, a receipt that freed the DHT path and each queued message's own timer all send, and
12464
+ * each of them took its turn while another was still between two steps: a message written later went out before one
12465
+ * written earlier, and the contact kept them that way. They now take turns (`turn`), and each turn sends what is
12466
+ * older first. Only a message the chat cannot carry (a long text while on the DHT) is passed by a later one.
12467
+ *
12468
+ * A link that closes takes its turn too (`disconnected`), and so does a receipt that never came. Both make a sent
12469
+ * message queued again, and both did it beside a look in progress: a link that dropped with a hundred texts
12470
+ * unconfirmed and came back within a second was looked at while half of them were between sent and queued, the look
12471
+ * passed those by and sent the later ones first. A look in progress when the link closes stops there: what it sent
12472
+ * on the old link may be lost, and the ones after them must not go on the new link before them. */
12287
12473
  var Outbox = class {
12288
12474
  store;
12289
12475
  send;
@@ -12295,8 +12481,18 @@ var Outbox = class {
12295
12481
  waiting = /* @__PURE__ */ new Map();
12296
12482
  attempts = /* @__PURE__ */ new Map();
12297
12483
  busy = /* @__PURE__ */ new Set();
12298
- /** In flight when the chat reopened (`flush({ reopened })`): sent again on the new link once that send returns. */
12484
+ /** Still in flight when the look a reopened chat asked for began (`flush({ reopened })`): sent again once that send returns. */
12299
12485
  reopenedWhileBusy = /* @__PURE__ */ new Set();
12486
+ /** The turns taken so far, one after the other (`turn`). */
12487
+ line = Promise.resolve();
12488
+ /** How many times the chat reopened, and the count each message last went out under: one sent since is on the new link. */
12489
+ opened = 0;
12490
+ sentUnder = /* @__PURE__ */ new Map();
12491
+ /** How many times the link closed with something unconfirmed on it, and the count when the turn in progress began. */
12492
+ closed = 0;
12493
+ began = 0;
12494
+ /** The link closed and what was unconfirmed on it is not queued again yet: the next turn does it first. */
12495
+ dropped = false;
12300
12496
  stopped = false;
12301
12497
  resender;
12302
12498
  policy;
@@ -12333,13 +12529,137 @@ var Outbox = class {
12333
12529
  else await this.queue(message, "Not confirmed before the app closed. It is sent again, under the same ID, once your contact is reachable.");
12334
12530
  }
12335
12531
  }
12336
- /** Sends one message now. `manual`: the Retry button — a new window and a fresh count of attempts. */
12337
- async transmit(id, { manual = false } = {}) {
12532
+ /**
12533
+ * One at a time, in the order asked: `run` starts when the turns before it are over. A turn that takes longer than
12534
+ * `TURN_MS` no longer holds the next one back.
12535
+ */
12536
+ turn(run) {
12537
+ const result = this.line.then(async () => {
12538
+ if (this.dropped) {
12539
+ this.dropped = false;
12540
+ await this.requeueUnconfirmed();
12541
+ }
12542
+ this.began = this.closed;
12543
+ return run();
12544
+ });
12545
+ this.line = new Promise((next) => {
12546
+ const timer = setTimeout(next, TURN_MS);
12547
+ result.then(() => {}, () => {}).then(() => {
12548
+ clearTimeout(timer);
12549
+ next();
12550
+ });
12551
+ });
12552
+ return result;
12553
+ }
12554
+ /** The link has not closed since this turn began: what the turn sends goes on the link the turn looked at. */
12555
+ get sameLink() {
12556
+ return !this.stopped && this.closed === this.began;
12557
+ }
12558
+ /** A new message a turn could not send (the link closed under it, or the ones before it still wait): it waits too. */
12559
+ async waitsItsTurn(message) {
12560
+ await this.store.update(message.id, "waiting", "Waits for the messages before it to go.", {
12561
+ via: "datalink",
12562
+ resendUntil: message.resendUntil ?? this.now() + this.policy.windowMs
12563
+ });
12564
+ this.schedule(message.id);
12565
+ }
12566
+ /**
12567
+ * Sends one message. A new one goes after the ones written before it that still wait to go: those the chat can
12568
+ * carry go first, and if it then cannot carry this one (the DHT path took one of them), it waits its turn too.
12569
+ * `manual`: the Retry button — it goes now, with a new window and a fresh count of attempts.
12570
+ */
12571
+ transmit(id, { manual = false } = {}) {
12572
+ return this.turn(async () => {
12573
+ if (manual || this.stopped || this.busy.has(id) || this.receipts.has(id)) return this.transmitNow(id, { manual });
12574
+ const messages = await this.store.read(), message = messages.find((m) => m.id === id);
12575
+ const before = message?.delivery === "sending" ? this.before(messages, message) : [];
12576
+ if (!before.length) return this.transmitNow(id, {}, message);
12577
+ const passed = await this.inOrder(before);
12578
+ if (this.stopped) return;
12579
+ if (this.sameLink && this.resender.ready({
12580
+ ...message,
12581
+ via: "datalink"
12582
+ })) {
12583
+ await this.catchUp(passed);
12584
+ if (this.sameLink) return this.transmitNow(id);
12585
+ }
12586
+ if (this.stopped) return;
12587
+ await this.waitsItsTurn(message);
12588
+ });
12589
+ }
12590
+ /** The messages written before a new one that have not gone yet, oldest first. */
12591
+ before(messages, message) {
12592
+ const stored = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sending" && !!m.wireId && m.timestamp < message.timestamp && !this.receipts.has(m.id) && !this.busy.has(m.id);
12593
+ return [...this.due(messages.filter((m) => m.id !== message.id && m.timestamp <= message.timestamp)), ...messages.filter(stored)].sort(oldestFirst);
12594
+ }
12595
+ /**
12596
+ * What a look at the chat takes up, oldest first: what is queued or waiting, and what was sent and still awaits its
12597
+ * receipt when the path it took is not the one the chat has now (it went on the DHT and the chat is live: it goes on
12598
+ * the link, before what was written after it) or when the chat reopened since (`opened`).
12599
+ */
12600
+ due(messages, opened) {
12601
+ const unconfirmed = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sent" && this.receipts.has(m.id);
12602
+ return messages.filter((m) => !this.busy.has(m.id) && (toGo(m) || this.file(m) || unconfirmed(m) && (opened !== void 0 && (this.sentUnder.get(m.id) ?? 0) < opened || !!this.resender.via && this.resender.via(m) !== m.via && this.resender.ready(m)))).sort(oldestFirst);
12603
+ }
12604
+ /**
12605
+ * Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
12606
+ * handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
12607
+ * a moment before it carries text, and a look that began in that moment sent the later messages and left the first
12608
+ * ones behind.
12609
+ */
12610
+ /** Whether this chat's waiting files go through this outbox (`Resender.sendFile`). */
12611
+ get sendsFiles() {
12612
+ return !!this.resender.sendFile;
12613
+ }
12614
+ file(message) {
12615
+ return this.sendsFiles && waitingFile(message);
12616
+ }
12617
+ /** A waiting file is said on the link, after the older messages passed by that can go now. False: the link cannot carry it. */
12618
+ async fileGoes(message, passed) {
12619
+ if (!this.resender.ready(message)) {
12620
+ if (!this.waiting.get(message.id)?.timer) this.schedule(message.id);
12621
+ return false;
12622
+ }
12623
+ if (passed) await this.catchUp(passed);
12624
+ if (!this.sameLink) return true;
12625
+ await this.resender.sendFile(message).catch(() => {});
12626
+ this.forget(message.id);
12627
+ return true;
12628
+ }
12629
+ async inOrder(messages) {
12630
+ const passed = [];
12631
+ for (const message of messages) {
12632
+ if (!this.sameLink) break;
12633
+ if (message.delivery === "sending") {
12634
+ await this.catchUp(passed);
12635
+ if (this.sameLink) await this.transmitNow(message.id);
12636
+ else if (!this.stopped) await this.waitsItsTurn(message);
12637
+ continue;
12638
+ }
12639
+ if (!await (this.file(message) ? this.fileGoes(message, passed) : this.again(message, passed))) passed.push(message);
12640
+ }
12641
+ return passed;
12642
+ }
12643
+ /** The messages passed by that the chat can carry by now go, oldest first; the others stay where they are. */
12644
+ async catchUp(passed) {
12645
+ for (let i = 0; i < passed.length && this.sameLink;) {
12646
+ if (!this.resender.ready(passed[i])) {
12647
+ i++;
12648
+ continue;
12649
+ }
12650
+ const [older] = passed.splice(i, 1);
12651
+ if (this.file(older)) await this.fileGoes(older);
12652
+ else await this.attempt(older.id);
12653
+ i = 0;
12654
+ }
12655
+ }
12656
+ /** `read`: the message's row, when it was read just now with nothing awaited since. */
12657
+ async transmitNow(id, { manual = false } = {}, read) {
12338
12658
  if (this.stopped || this.busy.has(id) || this.receipts.has(id)) return;
12339
12659
  this.busy.add(id);
12340
12660
  let again;
12341
12661
  try {
12342
- let message = (await this.store.read()).find((m) => m.id === id);
12662
+ let message = read ?? (await this.store.read()).find((m) => m.id === id);
12343
12663
  if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
12344
12664
  const wait = this.waiting.get(id);
12345
12665
  if (wait?.timer) {
@@ -12366,6 +12686,7 @@ var Outbox = class {
12366
12686
  return;
12367
12687
  }
12368
12688
  this.track(id, typeof this.timeoutMs === "number" ? this.timeoutMs : this.timeoutMs(message));
12689
+ this.sentUnder.set(id, this.opened);
12369
12690
  const error = await this.send(message);
12370
12691
  if (error) {
12371
12692
  this.clear(id);
@@ -12406,25 +12727,53 @@ var Outbox = class {
12406
12727
  * oldest first. `reopened`: a new link, so what still awaits a receipt from the old one, or from the DHT, goes on it too
12407
12728
  * (a receipt on the link also ends the DHT's own retries).
12408
12729
  */
12409
- async flush({ reopened = false } = {}) {
12410
- if (this.stopped || !reopened && !this.waiting.size) return;
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);
12730
+ flush({ reopened = false } = {}) {
12731
+ if (this.stopped || !reopened && !this.waiting.size) return Promise.resolve();
12732
+ const opened = reopened ? ++this.opened : void 0;
12733
+ return this.turn(async () => {
12734
+ if (this.stopped) return;
12735
+ if (reopened) for (const id of this.busy) this.reopenedWhileBusy.add(id);
12736
+ await this.inOrder(this.due(await this.store.read(), opened));
12737
+ });
12738
+ }
12739
+ /** A queued message's check came due: the ones written before it that have not gone are looked at first. */
12740
+ async check(id) {
12741
+ if (this.stopped) return;
12742
+ const messages = await this.store.read(), message = messages.find((m) => m.id === id);
12743
+ if (!message || !(toGo(message) || this.file(message))) {
12744
+ await this.attempt(id);
12420
12745
  return;
12421
12746
  }
12422
- if (message.delivery !== "sent" || !this.receipts.has(message.id)) return;
12747
+ await this.inOrder(this.due(messages.filter((m) => m.timestamp <= message.timestamp)));
12748
+ }
12749
+ /**
12750
+ * A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry
12751
+ * it. False when the chat could not carry it.
12752
+ */
12753
+ async again(message, passed) {
12754
+ if (message.sender !== "me" || message.via === "hold") return true;
12755
+ if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed);
12756
+ if (message.delivery !== "sent" || !this.receipts.has(message.id)) return true;
12423
12757
  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.");
12758
+ if (this.resender.ready(message)) {
12759
+ if (passed) await this.catchUp(passed);
12760
+ await this.transmitNow(message.id);
12761
+ return true;
12762
+ }
12763
+ await this.requeue(message.id, "Connection closed before receipt. Delivery is unconfirmed.");
12764
+ return false;
12765
+ }
12766
+ /**
12767
+ * The link closed: what awaits a receipt from it is queued again, in a turn of its own, so a look at the chat never
12768
+ * finds a message between sent and queued. A look in progress stops (`sameLink`).
12769
+ */
12770
+ disconnected() {
12771
+ if (!this.receipts.size) return Promise.resolve();
12772
+ this.closed++;
12773
+ this.dropped = true;
12774
+ return this.turn(async () => {});
12426
12775
  }
12427
- async disconnected() {
12776
+ async requeueUnconfirmed() {
12428
12777
  const messages = await this.store.read();
12429
12778
  for (const id of [...this.receipts.keys()]) {
12430
12779
  if (messages.find((m) => m.id === id)?.via === "pkarr") {
@@ -12440,33 +12789,40 @@ var Outbox = class {
12440
12789
  this.reopenedWhileBusy.clear();
12441
12790
  for (const wait of this.waiting.values()) if (wait.timer) clearTimeout(wait.timer);
12442
12791
  this.waiting.clear();
12443
- await this.disconnected();
12792
+ this.dropped = false;
12793
+ await this.requeueUnconfirmed();
12444
12794
  }
12445
- /** Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more. */
12446
- async attempt(id) {
12447
- if (this.stopped || this.busy.has(id)) return;
12795
+ /**
12796
+ * Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more.
12797
+ * False when the chat could not carry it and it still waits. `passed`: older messages the chat could not carry a
12798
+ * moment ago, which go first if it can now.
12799
+ */
12800
+ async attempt(id, passed) {
12801
+ if (this.stopped || this.busy.has(id)) return true;
12448
12802
  const message = (await this.store.read()).find((m) => m.id === id);
12449
12803
  if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
12450
12804
  this.forget(id);
12451
- return;
12805
+ return true;
12452
12806
  }
12453
12807
  if ((message.resendUntil ?? Infinity) <= this.now()) {
12454
12808
  if (message.delivery === "waiting") {
12455
12809
  this.forget(id);
12456
12810
  await this.store.update(id, "failed", `Your contact was not reachable within ${days(this.policy.windowMs)}. It was not sent; Retry sends it.`);
12457
12811
  } else await this.fail(id, `Not confirmed within ${days(this.policy.windowMs)}.`);
12458
- return;
12812
+ return true;
12459
12813
  }
12460
12814
  if (this.resender.ready(message)) {
12461
- await this.transmit(id);
12462
- return;
12815
+ if (passed) await this.catchUp(passed);
12816
+ await this.transmitNow(id);
12817
+ return true;
12463
12818
  }
12464
12819
  const wait = this.waiting.get(id);
12465
12820
  if (this.resender.divert && wait && this.now() - wait.since >= this.policy.holdAfterMs && await this.resender.divert(message)) {
12466
12821
  this.forget(id);
12467
- return;
12822
+ return true;
12468
12823
  }
12469
12824
  if (!wait?.timer) this.schedule(id);
12825
+ return false;
12470
12826
  }
12471
12827
  /** After a failed attempt: queued again, unless its window or its attempts are spent. */
12472
12828
  async requeue(id, reason) {
@@ -12501,7 +12857,7 @@ var Outbox = class {
12501
12857
  wait.timer = setTimeout(() => {
12502
12858
  wait.timer = void 0;
12503
12859
  wait.step++;
12504
- this.attempt(id).catch(() => {});
12860
+ this.turn(() => this.check(id)).catch(() => {});
12505
12861
  }, delay);
12506
12862
  this.waiting.set(id, wait);
12507
12863
  }
@@ -12510,17 +12866,20 @@ var Outbox = class {
12510
12866
  if (wait?.timer) clearTimeout(wait.timer);
12511
12867
  this.waiting.delete(id);
12512
12868
  this.attempts.delete(id);
12869
+ this.sentUnder.delete(id);
12513
12870
  }
12514
12871
  track(id, duration) {
12515
12872
  this.clear(id);
12516
- this.receipts.set(id, setTimeout(() => {
12517
- this.receipts.delete(id);
12518
- (async () => {
12873
+ const timer = setTimeout(() => {
12874
+ this.turn(async () => {
12875
+ if (this.receipts.get(id) !== timer) return;
12876
+ this.receipts.delete(id);
12519
12877
  const message = (await this.store.read()).find((m) => m.id === id);
12520
12878
  if (message?.via === "pkarr") await this.expired(message);
12521
12879
  else await this.requeue(id, "No receipt received yet.");
12522
- })().catch(() => {});
12523
- }, duration));
12880
+ }).catch(() => {});
12881
+ }, duration);
12882
+ this.receipts.set(id, timer);
12524
12883
  }
12525
12884
  clear(id) {
12526
12885
  const timer = this.receipts.get(id);
@@ -12758,7 +13117,8 @@ var ITEM = {
12758
13117
  invoice: (a) => `An invoice for ${a}, not paid yet`,
12759
13118
  paid: (a) => `${a} paid to an invoice, not claimed from the mint yet`,
12760
13119
  unclaimed: (a) => `${a} the mint says it issued for an invoice, never received here`,
12761
- sent: (a) => `${a} in ecash you sent, not taken yet`
13120
+ sent: (a) => `${a} in ecash you sent, not taken yet`,
13121
+ swap: (a) => `${a} in an exchange with the mint, not finished yet`
12762
13122
  };
12763
13123
  var ENGLISH_REMOVAL = {
12764
13124
  sats: (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`,
@@ -12783,8 +13143,13 @@ function items(type, network, view, words, card) {
12783
13143
  ...a.paymentId ? { paymentId: a.paymentId } : {}
12784
13144
  });
12785
13145
  const returns = (a) => a.kind === "sent" && type === "cashu";
13146
+ const swaps = type === "cashu" && view?.openSwaps ? [{
13147
+ kind: "swap",
13148
+ text: words.item("swap", amount(view.swapsAmount ?? 0)),
13149
+ amount: amount(view.swapsAmount ?? 0)
13150
+ }] : [];
12786
13151
  return {
12787
- awaiting: mine.filter((a) => !returns(a)).map(item),
13152
+ awaiting: [...mine.filter((a) => !returns(a)).map(item), ...swaps],
12788
13153
  returnable: mine.filter(returns).map(item)
12789
13154
  };
12790
13155
  }
@@ -12806,7 +13171,7 @@ function walletRemoval(type, network, view, intents = [], card, words = ENGLISH_
12806
13171
  case "cashu": return {
12807
13172
  ...base,
12808
13173
  custody: "device",
12809
- held: known(view?.balance ?? 0),
13174
+ held: known((view?.balance ?? 0) + (view?.setAside ?? 0)),
12810
13175
  backup: "tokens",
12811
13176
  pending
12812
13177
  };
@@ -13281,6 +13646,16 @@ var db = {
13281
13646
  want += newest.length - page.length;
13282
13647
  }
13283
13648
  },
13649
+ /**
13650
+ * Every message of this profile that carries a task or a routine card, from every chat and group, oldest first (time,
13651
+ * then id). Reads the card index only (`CARD_INDEX`): no chat's history is read, however long. The index follows the
13652
+ * rows, so an edit's card, a deleted message and a deleted chat or group are in step with no bookkeeping.
13653
+ */
13654
+ async getCardMessages() {
13655
+ const index = (await store(STORES.messages, "readonly")).index(CARD_INDEX);
13656
+ const [tasks, routines] = await Promise.all([wrap(index.getAll("task")), wrap(index.getAll("routine"))]);
13657
+ return [...tasks, ...routines].sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
13658
+ },
13284
13659
  /** Returns false when the message was already stored. */
13285
13660
  async addMessage(message) {
13286
13661
  const tx = (await openDb()).transaction(STORES.messages, "readwrite");
@@ -13381,6 +13756,32 @@ var db = {
13381
13756
  }
13382
13757
  };
13383
13758
  //#endregion
13759
+ //#region ../browser/src/engine/arrival.ts
13760
+ /**
13761
+ * A history stored before a received row was placed where it arrived (WISP 400, requirement 10; until 2026-10): a
13762
+ * contact's row sits under the time the contact's clock said, up to five minutes ahead of this device's. Almost all of
13763
+ * it stays as it is: its stored time is its place, so nothing moves when the app updates. Only the rows whose stored
13764
+ * time has not come yet are settled, once, at the start: they are the last rows of their chat, and left there a
13765
+ * message written in the next minutes would sort above them. Each takes a place just before now, in the order they
13766
+ * were in and after everything before them, and keeps the time its sender said beside it (`sentAt`).
13767
+ *
13768
+ * Returns the rows to write, as they are now; none when nothing is ahead. Rows of mine are never touched: their time
13769
+ * is the one both sides know them by.
13770
+ */
13771
+ function settleAhead(history, now) {
13772
+ const ahead = history.filter((m) => m.sender === "peer" && m.sentAt === void 0 && m.timestamp > now);
13773
+ if (!ahead.length) return [];
13774
+ let last = history.reduce((max, m) => m.timestamp > now ? max : Math.max(max, m.timestamp), 0);
13775
+ return ahead.map((m, i) => {
13776
+ last = Math.max(last + 1, now - (ahead.length - i));
13777
+ return {
13778
+ ...m,
13779
+ timestamp: last,
13780
+ sentAt: m.timestamp
13781
+ };
13782
+ });
13783
+ }
13784
+ //#endregion
13384
13785
  //#region ../browser/src/engine/reactions.ts
13385
13786
  var isGroup$1 = (chat) => chat.startsWith("group:");
13386
13787
  /** The row a reaction names in a chat: the id both sides know it by (`replyRef`). */
@@ -13721,6 +14122,36 @@ var HUB_GRACE_MS = 18e4;
13721
14122
  var REFUSED_FOR_MS$1 = 6e5;
13722
14123
  var ENTRY_LINGER_MS$1 = 2e4;
13723
14124
  var RELAYED_KEPT = 4096;
14125
+ /**
14126
+ * Someone the chain took out while its app was closed comes back believing it is a member: it asks in a hub's lobby,
14127
+ * or lists itself as a hub. A hub then keeps an edge to it for a moment, only to hand it the commit that took it out
14128
+ * (`farewell`): at most `FAREWELL_EDGES` at a time, each awaited `FAREWELL_WAIT_MS` and closed `FAREWELL_LINGER_MS`
14129
+ * after the commit went; the same key is not waited for again for a while (longer once it was told).
14130
+ */
14131
+ var FAREWELL_EDGES = 4;
14132
+ var FAREWELL_WAIT_MS = 12e4;
14133
+ var FAREWELL_LINGER_MS = 1e4;
14134
+ var FAREWELL_RETRY_MS = 6e4;
14135
+ var FAREWELL_TOLD_MS = 6e5;
14136
+ var FAREWELL_SAY_MS = 1e4;
14137
+ /**
14138
+ * A key asking from before the commit that took it out was there and was sent that commit: not waited for. The two
14139
+ * times are two devices' clocks (the asker's, the commit's signer's), so "before" allows this much between them:
14140
+ * a returning member whose clock runs behind the admin's is told all the same.
14141
+ */
14142
+ var FAREWELL_SKEW_MS = 3e5;
14143
+ /**
14144
+ * A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
14145
+ * what this app saw is taken as written; past it the two clocks differ, and the entry is read in this app's time
14146
+ * (`inMyTime`). Half as long again as a hub's pace: what an entry may trail a reading by, between two clocks that agree.
14147
+ */
14148
+ var HUB_CLOCK_SLACK_MS = COMMUNITY_TOPOLOGY.beaconEveryMs * 1.5;
14149
+ /** Two readings of the beacon no further apart than this say when an entry that moved between them was written. */
14150
+ var HUB_CLOCK_READ_GAP_MS = 45e3;
14151
+ /** A hub's clock is remembered this long after its entry was last in the beacon. */
14152
+ var HUB_CLOCK_KEPT_MS = 6e5;
14153
+ /** A member that opens the group's link again knocks to check it is still in, this long at most (`joinByLink`). */
14154
+ var CHECK_MS = 18e4;
13724
14155
  var MESSAGE_LINK$1 = (groupId) => `group:${groupId}`;
13725
14156
  /**
13726
14157
  * A member key for a joiner whose entry session the member's side dials. Of a paired session's two
@@ -13752,6 +14183,8 @@ var Communities = class {
13752
14183
  /** Joiners: groups whose knock is out (published), and the knock record it is in. */
13753
14184
  knocked = /* @__PURE__ */ new Set();
13754
14185
  knockAt = /* @__PURE__ */ new Map();
14186
+ /** Joiners: how many others had a knock still being refreshed in the record I last read (they wait with me). */
14187
+ knockingWith = /* @__PURE__ */ new Map();
13755
14188
  /** My frames an edge took, and which of them carry an edit (`frame id → editKey`). */
13756
14189
  frames = new FramesTaken();
13757
14190
  carriers = /* @__PURE__ */ new Map();
@@ -13787,7 +14220,8 @@ var Communities = class {
13787
14220
  }
13788
14221
  /** Is this device a hub of the group? (tests and the load harness) */
13789
14222
  isHub(groupId) {
13790
- return !!this.live.get(groupId)?.hub;
14223
+ const live = this.live.get(groupId);
14224
+ return !!live?.hub && live.session.status === "active";
13791
14225
  }
13792
14226
  async load(groups) {
13793
14227
  for (const group of groups) {
@@ -13796,9 +14230,11 @@ var Communities = class {
13796
14230
  const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
13797
14231
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
13798
14232
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
13799
- if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
14233
+ const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
14234
+ if (peerAt) this.lastPeerMessageAt.set(group.id, peerAt);
13800
14235
  const mention = [...history].reverse().find((m) => m.mentioned);
13801
- if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
14236
+ const mentionAt = cameOrWritten(mention?.timestamp, mention ? group.came?.mention : void 0);
14237
+ if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
13802
14238
  for (const [, linkId] of this.host.entries(group.id)) if (group.joining?.linkId !== linkId) await this.host.closeEdge(linkId);
13803
14239
  const live = this.live.get(group.id);
13804
14240
  if (live) for (const key of this.host.edges(group.id).keys()) live.restored.set(key, 0);
@@ -13821,7 +14257,7 @@ var Communities = class {
13821
14257
  invited: [],
13822
14258
  memberLinks: {}
13823
14259
  };
13824
- if (group.joining && (!live || live.session.status === "lost")) return [{
14260
+ if (this.knocking(group, live)) return [{
13825
14261
  ...base,
13826
14262
  name: groupName(group.joining.name) ?? live?.session.name ?? "",
13827
14263
  isAdmin: false,
@@ -13834,7 +14270,8 @@ var Communities = class {
13834
14270
  members: 0,
13835
14271
  accepted: true,
13836
14272
  viaLink: true,
13837
- stage: this.joinStage(group)
14273
+ stage: this.joinStage(group),
14274
+ ...this.knockingWith.get(group.id) ? { waiting: this.knockingWith.get(group.id) } : {}
13838
14275
  }
13839
14276
  }];
13840
14277
  if (!live) return [];
@@ -13843,7 +14280,7 @@ var Communities = class {
13843
14280
  const id = edges.get(key);
13844
14281
  return !!id && this.host.linkReady(id, 2);
13845
14282
  };
13846
- const hubs = freshHubs(live.beacon, this.now()).map((h) => h.key);
14283
+ const hubs = freshHubs(this.hubs(live), this.now()).map((h) => h.key);
13847
14284
  return [{
13848
14285
  ...base,
13849
14286
  name: s.name,
@@ -13904,17 +14341,36 @@ var Communities = class {
13904
14341
  if (!link) throw new Error("This is not a link to a group");
13905
14342
  const existing = this.stored.get(link.g);
13906
14343
  const live = this.live.get(link.g);
13907
- if (live && live.session.status === "active") return link.g;
13908
14344
  if (existing?.joining && existing.joining.host === link.host) return link.g;
14345
+ if (live) {
14346
+ const s = live.session;
14347
+ if (s.status === "active" && s.roster.length === 1) return link.g;
14348
+ if (s.status === "active") {
14349
+ await this.startJoining(link, s.state.seedB64, this.now(), true);
14350
+ return link.g;
14351
+ }
14352
+ if (s.status === "removed" || s.status === "lost") {
14353
+ await this.startJoining(link, s.state.seedB64, this.now());
14354
+ return link.g;
14355
+ }
14356
+ }
13909
14357
  if (existing) await this.forget(link.g);
13910
14358
  await this.startJoining(link, dialedKey(link), this.now());
13911
14359
  return link.g;
13912
14360
  }
13913
- async startJoining(link, seedB64, since) {
14361
+ /** Knocking to get in: a joiner, or someone who was in (removed, or its admission lost) asking again. Not a member checking it still is. */
14362
+ knocking(group, live) {
14363
+ return !!group.joining && (!live || live.session.status !== "active");
14364
+ }
14365
+ /** `check`: a member, as far as this device knows, asking the door whether it still is (`joinByLink`). */
14366
+ async startJoining(link, seedB64, since, check = false) {
13914
14367
  this.lastKnock.delete(link.g);
13915
14368
  this.knocked.delete(link.g);
13916
14369
  this.knockAt.delete(link.g);
14370
+ this.knockingWith.delete(link.g);
14371
+ const before = this.stored.get(link.g)?.joining;
13917
14372
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
14373
+ if (before && before.linkId !== linkId) await this.host.closeEdge(before.linkId);
13918
14374
  const group = this.stored.get(link.g) ?? {
13919
14375
  id: link.g,
13920
14376
  createdAt: since
@@ -13927,7 +14383,8 @@ var Communities = class {
13927
14383
  name: group.community?.name ?? "",
13928
14384
  inviter: "",
13929
14385
  pieces: [],
13930
- since
14386
+ since,
14387
+ ...check && { check: true }
13931
14388
  };
13932
14389
  this.stored.set(link.g, group);
13933
14390
  await this.store.putGroup(group);
@@ -13935,9 +14392,29 @@ var Communities = class {
13935
14392
  traceJoin(link.g, "join.start");
13936
14393
  this.knock(group, since).catch(() => {});
13937
14394
  }
14395
+ /** The check is over (answered, or nobody was at the door): the group stays as it is here. */
14396
+ async endCheck(group) {
14397
+ const joining = group.joining;
14398
+ if (!joining) return;
14399
+ delete group.joining;
14400
+ this.lastKnock.delete(group.id);
14401
+ this.knocked.delete(group.id);
14402
+ this.knockAt.delete(group.id);
14403
+ this.knockingWith.delete(group.id);
14404
+ await this.host.closeEdge(joining.linkId);
14405
+ await this.store.putGroup(group);
14406
+ this.host.emit();
14407
+ }
13938
14408
  async send(groupId, text, mentions = [], reply, forwarded, card) {
13939
14409
  const live = this.live.get(groupId);
13940
- if (!live) return { error: "You are not in this group yet" };
14410
+ if (!live) return {
14411
+ error: "You are not in this group yet",
14412
+ refused: true
14413
+ };
14414
+ if (!live.session.isMember) return {
14415
+ error: live.session.state.statusReason ?? "You are not in this group",
14416
+ refused: true
14417
+ };
13941
14418
  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);
13942
14419
  return "error" in result ? { error: result.error } : {
13943
14420
  error: null,
@@ -14050,7 +14527,7 @@ var Communities = class {
14050
14527
  */
14051
14528
  async decline(groupId) {
14052
14529
  const group = this.stored.get(groupId), live = this.live.get(groupId);
14053
- if (!group?.joining || live && live.session.status !== "lost") throw new Error("No invitation to decline");
14530
+ if (!group || !this.knocking(group, live)) throw new Error("No invitation to decline");
14054
14531
  if (live) throw new Error("You were in this group: leave it to stop trying to get back in");
14055
14532
  await this.forget(groupId);
14056
14533
  }
@@ -14062,6 +14539,7 @@ var Communities = class {
14062
14539
  this.lastMessageAt.delete(groupId);
14063
14540
  this.lastPeerMessageAt.delete(groupId);
14064
14541
  this.lastMentionAt.delete(groupId);
14542
+ this.knockingWith.delete(groupId);
14065
14543
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
14066
14544
  await this.store.deleteGroup(groupId);
14067
14545
  this.host.historyGone?.(groupId);
@@ -14077,15 +14555,17 @@ var Communities = class {
14077
14555
  this.time = now;
14078
14556
  for (const group of [...this.stored.values()]) {
14079
14557
  const live = this.live.get(group.id);
14080
- if (group.joining && (!live || live.session.status === "lost")) {
14081
- const every = now - group.joining.since > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
14082
- if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
14558
+ if (group.joining && (this.knocking(group, live) || group.joining.check)) {
14559
+ const waited = now - group.joining.since, every = waited > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
14560
+ if (!this.knocking(group, live) && waited > CHECK_MS) await this.endCheck(group);
14561
+ else if (!this.host.linkReady(group.joining.linkId, 2) && !this.host.linkSeen?.(group.joining.linkId) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
14083
14562
  if (!live) continue;
14084
14563
  }
14085
14564
  if (!live) continue;
14086
14565
  if (live.session.status === "lost" && !group.joining) {
14087
14566
  if (live.hub) {
14088
14567
  live.hub = false;
14568
+ live.leaving = 0;
14089
14569
  await this.publishBeacon(group.id, live, now, false).catch(() => {});
14090
14570
  }
14091
14571
  await this.closeEntries(group.id);
@@ -14096,6 +14576,12 @@ var Communities = class {
14096
14576
  continue;
14097
14577
  }
14098
14578
  if (live.session.status !== "active") {
14579
+ if (live.hub) {
14580
+ live.hub = false;
14581
+ await this.closeEntries(group.id);
14582
+ await this.publishBeacon(group.id, live, now, false).catch(() => {});
14583
+ }
14584
+ live.farewells.clear();
14099
14585
  await this.reconcile(group.id, live, now);
14100
14586
  continue;
14101
14587
  }
@@ -14111,14 +14597,19 @@ var Communities = class {
14111
14597
  live.lingering.delete(linkId);
14112
14598
  if ([...this.host.entries(groupId).values()].includes(linkId)) await this.host.closeEdge(linkId);
14113
14599
  }
14114
- const alone = live.hub && !freshHubs(live.beacon, now).some((h) => h.key !== me);
14115
- if (now - live.lastBeaconRead >= (alone ? COMMUNITY_TOPOLOGY.beaconEveryMs : this.timings.beaconReadMs) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
14116
- const others = freshHubs(live.beacon, now).filter((h) => h.key !== me);
14600
+ const alone = live.hub && !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
14601
+ if ((live.beaconFailedAt ? now - live.beaconFailedAt >= BEACON_RETRY_MS : now - live.lastBeaconRead >= (alone ? COMMUNITY_TOPOLOGY.beaconEveryMs : this.timings.beaconReadMs)) || live.lastBeaconRead === 0) await this.readBeacon(groupId, live, now);
14602
+ if (!live.beaconKnown) {
14603
+ await this.keepLooking(groupId, live, now);
14604
+ await this.reconcile(groupId, live, now);
14605
+ return;
14606
+ }
14607
+ const others = freshHubs(this.hubs(live), now).filter((h) => h.key !== me);
14117
14608
  if (!live.hub) {
14118
- const settled = () => now - live.joinedAt >= this.timings.newcomerMs || !freshHubs(live.beacon, now).some((h) => h.key !== me);
14119
- const want = () => settled() && (shouldBeHub(me, live.beacon, now) || !!live.forceHub && freshHubs(live.beacon, now).length < COMMUNITY_TOPOLOGY.maxHubs) && this.capacity(groupId, live, now) > 0;
14609
+ const settled = () => now - live.joinedAt >= this.timings.newcomerMs || !freshHubs(this.hubs(live), now).some((h) => h.key !== me);
14610
+ const want = () => settled() && (shouldBeHub(me, this.hubs(live), now) || !!live.forceHub && freshHubs(this.hubs(live), now).length < COMMUNITY_TOPOLOGY.maxHubs) && this.capacity(groupId, live, now) > 0;
14120
14611
  if (want()) {
14121
- if (!live.hubCandidateAt) live.hubCandidateAt = now + (freshHubs(live.beacon, now).length ? Math.floor(this.random() * this.timings.hubJitterMs) : 0);
14612
+ if (!live.hubCandidateAt) live.hubCandidateAt = now + (freshHubs(this.hubs(live), now).length ? Math.floor(this.random() * this.timings.hubJitterMs) : 0);
14122
14613
  if (now >= live.hubCandidateAt) {
14123
14614
  live.hubCandidateAt = 0;
14124
14615
  if (live.lastBeaconRead !== now) await this.readBeacon(groupId, live, now);
@@ -14127,6 +14618,7 @@ var Communities = class {
14127
14618
  live.hub = true;
14128
14619
  live.hubSince = now;
14129
14620
  live.emptySince = now;
14621
+ live.leaving = 0;
14130
14622
  live.knocksScanned = false;
14131
14623
  await this.publishBeacon(groupId, live, now, true).catch(() => {});
14132
14624
  traceJoin(groupId, "hub.elected");
@@ -14137,15 +14629,45 @@ var Communities = class {
14137
14629
  if (live.hub) {
14138
14630
  const load = this.hubLoad(groupId, live, now);
14139
14631
  if (load > 0) live.emptySince = now;
14140
- const linkedToHub = [...this.host.edges(groupId)].some(([key, id]) => this.peerIsHub(live, key) && this.host.linkReady(id, 2));
14141
- if (now - live.emptySince > this.timings.idleHubMs && others.length >= COMMUNITY_TOPOLOGY.minHubs && now - live.hubSince > this.timings.idleHubMs && linkedToHub) {
14142
- live.hub = false;
14143
- await this.closeEntries(groupId);
14144
- await this.publishBeacon(groupId, live, now, false);
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(() => {});
14632
+ const idle = now - live.emptySince > this.timings.idleHubMs && now - live.hubSince > this.timings.idleHubMs;
14633
+ if (live.leaving) {
14634
+ const read = live.beaconAt > live.leaving, listed = live.beacon.some((h) => h.key === me);
14635
+ if (read && idle && !listed && this.canStepDown(groupId, live, now)) {
14636
+ live.leaving = 0;
14637
+ live.hub = false;
14638
+ const edges = this.host.edges(groupId);
14639
+ live.myHubs = freshHubs(this.hubs(live), now).map((h) => h.key).filter((key) => {
14640
+ const id = edges.get(key);
14641
+ return key !== me && !!id && this.host.linkReady(id, 2);
14642
+ }).slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
14643
+ for (const key of live.myHubs) {
14644
+ live.hubsAvoided.delete(key);
14645
+ live.hubWaits.delete(key);
14646
+ live.hubsUp.add(key);
14647
+ }
14648
+ live.members.clear();
14649
+ } else if (read && idle && listed && this.canStepDown(groupId, live, now)) {
14650
+ if (await this.publishBeacon(groupId, live, now, false).then(() => true, () => false)) live.leaving = now;
14651
+ } else if (read || now - live.leaving > COMMUNITY_TOPOLOGY.beaconEveryMs) {
14652
+ live.leaving = 0;
14653
+ live.stepDownAt = now + this.timings.idleHubMs + Math.floor(this.random() * this.timings.idleHubMs);
14654
+ }
14655
+ } else if (idle && now >= live.stepDownAt && this.canStepDown(groupId, live, now)) {
14656
+ live.stepDownAt = now + 3 * BEACON_RETRY_MS;
14657
+ if ((live.beaconAt === now || await this.readBeacon(groupId, live, now)) && this.canStepDown(groupId, live, now) && await this.publishBeacon(groupId, live, now, false).then(() => true, () => false)) {
14658
+ live.leaving = now;
14659
+ await this.closeEntries(groupId);
14660
+ }
14661
+ }
14662
+ if (!live.hub || live.leaving) {} 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(() => {});
14663
+ }
14664
+ if (live.hub) {
14665
+ for (const h of freshHubs(live.beacon, now)) if (h.ts + FAREWELL_SKEW_MS > (s.outAt(h.key) ?? Infinity)) this.wantFarewell(groupId, live, h.key, now);
14146
14666
  }
14667
+ this.endFarewells(live, now);
14147
14668
  if (live.hub) {
14148
14669
  const busy = this.admitting(groupId, live, now);
14670
+ if (s.state.pendingLeaves.some((r) => r.s !== me && this.recentHub(live, r.s))) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
14149
14671
  const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
14150
14672
  if (now - live.lastLobbyPoll >= lobbyEvery) {
14151
14673
  live.lastLobbyPoll = now;
@@ -14157,26 +14679,41 @@ var Communities = class {
14157
14679
  const edges = this.host.edges(groupId);
14158
14680
  const fresh = new Set(others.map((h) => h.key));
14159
14681
  for (const [key, until] of live.hubsAvoided) if (until <= now) live.hubsAvoided.delete(key);
14682
+ const gone = live.myHubs.filter((key) => s.wasRemoved(key) || s.state.pendingLeaves.some((r) => r.s === key));
14683
+ for (const key of gone) {
14684
+ live.hubsAvoided.set(key, now + HUB_GRACE_MS);
14685
+ live.hubWaits.delete(key);
14686
+ live.hubsUp.delete(key);
14687
+ }
14160
14688
  for (const key of live.myHubs) {
14689
+ if (gone.includes(key)) continue;
14161
14690
  const id = edges.get(key);
14162
14691
  if (id && this.host.linkReady(id, 2)) {
14163
14692
  live.hubWaits.delete(key);
14164
14693
  live.hubsUp.add(key);
14694
+ live.replacing?.delete(key);
14165
14695
  continue;
14166
14696
  }
14167
14697
  const since = live.hubWaits.get(key) ?? now;
14168
14698
  live.hubWaits.set(key, since);
14169
14699
  const back = live.hubsUp.has(key) && !!id && !!this.host.linkBack?.(id);
14170
- if (now - since > (back ? 3 : 1) * this.timings.hubWaitMs) {
14700
+ if (now - since > (back || live.replacing?.has(key) ? 3 : 1) * this.timings.hubWaitMs) {
14171
14701
  live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
14172
14702
  live.hubWaits.delete(key);
14173
14703
  live.hubsUp.delete(key);
14704
+ live.replacing?.delete(key);
14174
14705
  }
14175
14706
  }
14176
14707
  let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
14177
14708
  kept = kept.slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
14178
14709
  const picked = pickHubs(me, others, now, new Set(live.hubsAvoided.keys()));
14179
- for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key)) kept.push(key);
14710
+ for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key)) {
14711
+ kept.push(key);
14712
+ if (gone.length) {
14713
+ if (!edges.has(key)) live.expect.add(key);
14714
+ (live.replacing ??= /* @__PURE__ */ new Set()).add(key);
14715
+ }
14716
+ }
14180
14717
  live.myHubs = kept;
14181
14718
  live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
14182
14719
  for (const hub of kept) {
@@ -14200,7 +14737,7 @@ var Communities = class {
14200
14737
  }
14201
14738
  if (s.needsCatchUp && now - (live.lastCatchUp ?? 0) >= 5e3 || now - (live.lastCatchUp ?? 0) >= 3e4) {
14202
14739
  live.lastCatchUp = now;
14203
- const ready = [...this.host.edges(groupId)].filter(([, id]) => this.host.linkReady(id, 2)).map(([key]) => key);
14740
+ const ready = [...this.host.edges(groupId)].filter(([key, id]) => this.host.linkReady(id, 2) && !s.wasRemoved(key)).map(([key]) => key);
14204
14741
  for (const key of ready.slice(0, 3)) s.catchUp(key);
14205
14742
  }
14206
14743
  await this.reconcile(groupId, live, now);
@@ -14226,8 +14763,32 @@ var Communities = class {
14226
14763
  if (id && !this.host.linkReady(id, 2)) this.host.expectPeer(id);
14227
14764
  }
14228
14765
  }
14766
+ /**
14767
+ * May this idle hub step down? When enough other hubs are listed and it has an edge up to one that is listed now
14768
+ * (a hub cut off from the others would only come back as one). It then steps down in two steps: it takes itself
14769
+ * out of the beacon, still a hub, and is a member only once a later reading still lists a hub it has an edge to.
14770
+ * Before, it was a member at once, and counted as hubs those it remembered from earlier readings. Hubs elected in
14771
+ * the same seconds (the members of a hub that left) were idle in the same second a minute later, and two of them
14772
+ * with an edge to each other only both stepped down, each counting on the other: each was the other's hub, neither
14773
+ * was one, and the two heard nobody else until the memory of them ran out, minutes later. And a hub whose write the
14774
+ * relays held back was no hub any more but stayed listed, so members went on asking it (2026-10-01).
14775
+ */
14776
+ canStepDown(groupId, live, now) {
14777
+ const me = live.session.myKey, others = freshHubs(this.hubs(live), now).filter((h) => h.key !== me);
14778
+ if (others.length < COMMUNITY_TOPOLOGY.minHubs) return false;
14779
+ const edges = this.host.edges(groupId);
14780
+ return others.some((h) => {
14781
+ const id = edges.get(h.key);
14782
+ return !!id && this.host.linkReady(id, 2);
14783
+ });
14784
+ }
14229
14785
  hubLoad(groupId, live, now) {
14230
14786
  const edges = this.host.edges(groupId);
14787
+ if (now - live.beaconAt <= 2 * this.timings.beaconReadMs) {
14788
+ const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
14789
+ for (const [key, id] of edges) if (!hubs.has(key) && !live.members.has(key) && this.host.linkReady(id, 2) && !live.session.wasRemoved(key)) live.members.set(key, now);
14790
+ for (const key of [...live.members.keys()]) if (hubs.has(key)) live.members.delete(key);
14791
+ }
14231
14792
  for (const [key, since] of live.members) {
14232
14793
  const id = edges.get(key);
14233
14794
  if (id && this.host.linkReady(id, 2)) live.members.set(key, now);
@@ -14258,7 +14819,7 @@ var Communities = class {
14258
14819
  capacity(groupId, live, now, entries = true) {
14259
14820
  const room = this.host.peerRoom?.(groupId);
14260
14821
  if (room === void 0) return COMMUNITY_TOPOLOGY.hubCapacity;
14261
- const hubs = new Set(freshHubs(live.beacon, now).map((h) => h.key));
14822
+ const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
14262
14823
  for (const key of this.host.edges(groupId).keys()) if (!live.members.has(key)) hubs.add(key);
14263
14824
  hubs.delete(live.session.myKey);
14264
14825
  const held = hubs.size + (entries ? this.host.entries(groupId).size : 0);
@@ -14269,13 +14830,72 @@ var Communities = class {
14269
14830
  const n = live.members.size;
14270
14831
  return n >= this.capacity(groupId, live, now, false) ? Math.max(n, COMMUNITY_TOPOLOGY.hubCapacity) : n;
14271
14832
  }
14833
+ /** Reads the beacon; false when the reading did not happen (the relays held it back, or none answered). */
14272
14834
  async readBeacon(groupId, live, now) {
14273
14835
  live.lastBeaconRead = now;
14274
14836
  const keys = beaconKeys(live.session.state.rv, groupId);
14275
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null);
14276
- live.beacon = readBeacon(keys, records ?? []);
14837
+ const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
14838
+ if (records === void 0) {
14839
+ live.beaconFailedAt = now;
14840
+ live.beaconAskedAt ||= now;
14841
+ if (live.session.roster.length === 1 || now - live.beaconAskedAt >= COMMUNITY_TOPOLOGY.beaconFreshMs) live.beaconKnown = true;
14842
+ return false;
14843
+ }
14844
+ live.beaconFailedAt = 0;
14845
+ live.beaconKnown = true;
14846
+ live.beacon = this.inMyTime(live, readBeacon(keys, records ?? []), now);
14277
14847
  live.head = readBeaconHead(keys, records ?? []);
14848
+ live.beaconAt = now;
14278
14849
  this.noteHubs(live, now);
14850
+ return true;
14851
+ }
14852
+ /**
14853
+ * A reading of the beacon in this app's time. A hub dates its entry by its own clock; held against mine as it is, a
14854
+ * hub whose clock is a minute and a half from mine never looks fresh (its entry is "stale", or "not come yet"): a
14855
+ * member saw no hub and took itself for the only one, and a hub with such a clock was no hub to anyone. So a hub's
14856
+ * clock is learned from its entry: when the entry moved between two readings of mine, it was written between them,
14857
+ * and what it says the time was then, against my own, is how far the two clocks are apart. An entry of a clock
14858
+ * further than `HUB_CLOCK_SLACK_MS` from mine is moved by that much (its `since` with it); one closer, or one not
14859
+ * seen to move yet, is taken as written, as before. An entry that stops moving ages by my clock like any other.
14860
+ */
14861
+ inMyTime(live, read, now) {
14862
+ const gap = now - live.beaconSeenAt;
14863
+ for (const h of read) {
14864
+ if (h.key === live.session.myKey) continue;
14865
+ const known = live.hubClocks.get(h.key);
14866
+ const moved = !!known && h.ts > known.said && live.beaconSeenAt > 0 && gap <= HUB_CLOCK_READ_GAP_MS;
14867
+ live.hubClocks.set(h.key, {
14868
+ said: Math.max(h.ts, known?.said ?? 0),
14869
+ ahead: moved ? now - gap / 2 - h.ts : known?.ahead,
14870
+ at: now
14871
+ });
14872
+ }
14873
+ for (const [key, clock] of live.hubClocks) if (now - clock.at > HUB_CLOCK_KEPT_MS) live.hubClocks.delete(key);
14874
+ live.beaconSeenAt = now;
14875
+ return read.map((h) => {
14876
+ const ahead = this.clockAhead(live, h.key);
14877
+ return ahead ? {
14878
+ ...h,
14879
+ ts: h.ts + ahead,
14880
+ ...h.since !== void 0 && { since: h.since + ahead }
14881
+ } : h;
14882
+ });
14883
+ }
14884
+ /** How far my clock is ahead of a hub's (ms, negative when behind), when the two differ; 0 when they agree or nothing says. */
14885
+ clockAhead(live, hub) {
14886
+ const ahead = hub === live.session.myKey ? 0 : live.hubClocks.get(hub)?.ahead ?? 0;
14887
+ return Math.abs(ahead) > HUB_CLOCK_SLACK_MS ? Math.round(ahead) : 0;
14888
+ }
14889
+ /** Entries in my time, as each hub wrote them: what goes back into the beacon, so no hub's entry is dated by another's clock. */
14890
+ asWritten(live, hubs) {
14891
+ return hubs.map((h) => {
14892
+ const ahead = this.clockAhead(live, h.key);
14893
+ return ahead ? {
14894
+ ...h,
14895
+ ts: h.ts - ahead,
14896
+ ...h.since !== void 0 && { since: h.since - ahead }
14897
+ } : h;
14898
+ });
14279
14899
  }
14280
14900
  /**
14281
14901
  * The newest message frame I hold, for the beacon's head (WISP 9xx § Head): what a member's other profile on the
@@ -14296,9 +14916,11 @@ var Communities = class {
14296
14916
  live.lastBeaconTry = now;
14297
14917
  const keys = beaconKeys(live.session.state.rv, groupId);
14298
14918
  let existing = live.beacon, head = live.head;
14299
- if (live.lastBeaconRead !== now) {
14300
- const records = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null) ?? [];
14301
- existing = readBeacon(keys, records);
14919
+ if (live.lastBeaconRead !== now || live.beaconFailedAt === now) {
14920
+ const read = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
14921
+ if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
14922
+ const records = read ?? [];
14923
+ existing = this.inMyTime(live, readBeacon(keys, records), now);
14302
14924
  head = readBeaconHead(keys, records);
14303
14925
  }
14304
14926
  const hubs = mergeBeacon(existing, live.session.myKey, listed ? {
@@ -14308,15 +14930,80 @@ var Communities = class {
14308
14930
  since: live.hubSince || now
14309
14931
  } : null, now, (key) => !live.session.wasRemoved(key));
14310
14932
  const newest = newerHead(head, this.newestFrame(live), now);
14311
- await this.host.publish(keys.identity, beaconRecords(keys, hubs, newest), true);
14933
+ await this.host.publish(keys.identity, beaconRecords(keys, this.asWritten(live, hubs), newest), true);
14312
14934
  live.beacon = hubs;
14313
14935
  live.head = newest;
14314
14936
  this.noteHubs(live, now);
14315
14937
  live.lastBeaconWrite = now;
14316
- live.lastBeaconRead = now;
14938
+ live.lastBeaconRead = live.beaconAt = now;
14939
+ live.beaconFailedAt = 0;
14940
+ live.beaconKnown = true;
14941
+ }
14942
+ /**
14943
+ * The beacon's hubs, less those the chain took out: hubs are members. Someone removed while its app was closed lists
14944
+ * itself there when it comes back, believing it is a member, until it is told (`wantFarewell`).
14945
+ */
14946
+ hubs(live) {
14947
+ const top = live.session.top, memo = live.hubsMemo;
14948
+ if (memo && memo.beacon === live.beacon && memo.top === top) return memo.hubs;
14949
+ const hubs = live.beacon.filter((h) => !live.session.wasRemoved(h.key));
14950
+ live.hubsMemo = {
14951
+ beacon: live.beacon,
14952
+ top,
14953
+ hubs
14954
+ };
14955
+ return hubs;
14956
+ }
14957
+ /** A frame goes on an edge to a member; to someone the chain took out, only the commit that took it out. */
14958
+ mayHear(live, key, frame) {
14959
+ if (!live?.session.wasRemoved(key)) return true;
14960
+ const f = frame;
14961
+ return f.t === "group-commit" && (f.commit?.k === "remove" || f.commit?.k === "leave") && f.commit.s === key;
14962
+ }
14963
+ /** The edges of the group that may hear `frame`. */
14964
+ hearers(groupId, frame) {
14965
+ const live = this.live.get(groupId);
14966
+ return [...this.host.edges(groupId)].filter(([key]) => this.mayHear(live, key, frame));
14967
+ }
14968
+ /** As a hub: someone the chain took out is asking (in my lobby, or listing itself as a hub). An edge to tell it, within bounds. */
14969
+ wantFarewell(groupId, live, key, now) {
14970
+ if (key === live.session.myKey || live.farewells.has(key) || now < (live.farewellQuiet.get(key) ?? 0)) return;
14971
+ if (live.farewells.size >= FAREWELL_EDGES || this.capacity(groupId, live, now) <= 0) return;
14972
+ live.farewells.set(key, { since: now });
14973
+ if (!this.host.edges(groupId).has(key)) live.expect.add(key);
14974
+ }
14975
+ /** Farewell edges end: told a moment ago, never came, or back in the group; and a hub no more keeps none. */
14976
+ endFarewells(live, now) {
14977
+ for (const [key, f] of live.farewells) {
14978
+ const back = !live.session.wasRemoved(key);
14979
+ if (!back && live.hub && (f.told ? now - f.told < FAREWELL_LINGER_MS : now - f.since < FAREWELL_WAIT_MS)) continue;
14980
+ live.farewells.delete(key);
14981
+ if (!back) live.farewellQuiet.set(key, now + (f.told ? FAREWELL_TOLD_MS : FAREWELL_RETRY_MS));
14982
+ }
14983
+ for (const [key, until] of live.farewellQuiet) if (until <= now) live.farewellQuiet.delete(key);
14984
+ for (const [key, at] of live.farewellSaid) if (now - at >= FAREWELL_SAY_MS) live.farewellSaid.delete(key);
14985
+ }
14986
+ /**
14987
+ * Someone the chain took out says where it is (`group-sync`): it is handed the commits up to the one that took it
14988
+ * out, and nothing else (`CommunitySession.farewell`). Nothing else it says is heard.
14989
+ */
14990
+ sayFarewell(groupId, live, key, sync) {
14991
+ const now = this.now(), linkId = this.host.edges(groupId).get(key);
14992
+ if (!linkId || now - (live.farewellSaid.get(key) ?? -Infinity) < FAREWELL_SAY_MS) return;
14993
+ const frames = live.session.farewell(key, sync);
14994
+ if (!frames.length) return;
14995
+ live.farewellSaid.set(key, now);
14996
+ try {
14997
+ for (const frame of frames) this.host.sendOnLink(linkId, frame);
14998
+ } catch {
14999
+ return;
15000
+ }
15001
+ const waited = live.farewells.get(key);
15002
+ if (waited) waited.told = now;
15003
+ traceJoin(groupId, "farewell.sent");
14317
15004
  }
14318
15005
  noteHubs(live, now) {
14319
- for (const h of freshHubs(live.beacon, now)) live.seenHubs.set(h.key, now);
15006
+ for (const h of freshHubs(this.hubs(live), now)) live.seenHubs.set(h.key, now);
14320
15007
  for (const [key, at] of live.seenHubs) if (now - at > HUB_GRACE_MS) live.seenHubs.delete(key);
14321
15008
  }
14322
15009
  recentHub(live, key) {
@@ -14326,17 +15013,22 @@ var Communities = class {
14326
15013
  async askHub(groupId, live, hub, now) {
14327
15014
  const keys = lobbyKeys(live.session.state.rv, groupId, hub);
14328
15015
  const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true) ?? []);
15016
+ const theirs = now - this.clockAhead(live, hub);
14329
15017
  await this.host.publish(keys.identity, lobbyRecords(keys, mergeLobby(existing, {
14330
15018
  key: live.session.myKey,
14331
- ts: now
14332
- }, now)), true);
15019
+ ts: theirs
15020
+ }, theirs)), true);
14333
15021
  traceJoin(groupId, "lobby.written");
14334
15022
  }
14335
15023
  async pollLobby(groupId, live, now) {
14336
15024
  const keys = lobbyKeys(live.session.state.rv, groupId, live.session.myKey);
14337
15025
  const entries = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => null) ?? []);
14338
15026
  for (const { key, ts } of entries) {
14339
- if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey || live.session.wasRemoved(key)) continue;
15027
+ if (now - ts > COMMUNITY_TOPOLOGY.lobbyFreshMs || key === live.session.myKey) continue;
15028
+ if (live.session.wasRemoved(key)) {
15029
+ if (ts + FAREWELL_SKEW_MS > (live.session.outAt(key) ?? Infinity)) this.wantFarewell(groupId, live, key, now);
15030
+ continue;
15031
+ }
14340
15032
  live.emptySince = now;
14341
15033
  if (!live.members.has(key) && live.members.size >= this.capacity(groupId, live, now)) continue;
14342
15034
  if (!live.members.has(key)) {
@@ -14353,10 +15045,11 @@ var Communities = class {
14353
15045
  if (s.status === "active") {
14354
15046
  if (live.hub) {
14355
15047
  this.shed(groupId, live, now);
14356
- for (const h of freshHubs(live.beacon, now)) if (h.key !== s.myKey) wanted.add(h.key);
15048
+ for (const h of freshHubs(this.hubs(live), now)) if (h.key !== s.myKey) wanted.add(h.key);
14357
15049
  for (const key of live.members.keys()) wanted.add(key);
15050
+ for (const key of live.farewells.keys()) wanted.add(key);
14358
15051
  } else for (const key of live.myHubs) wanted.add(key);
14359
- for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key))) wanted.add(key);
15052
+ for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key)) && !s.wasRemoved(key)) wanted.add(key);
14360
15053
  }
14361
15054
  for (const [key, since] of live.restored) {
14362
15055
  if (!since) live.restored.set(key, now);
@@ -14411,7 +15104,7 @@ var Communities = class {
14411
15104
  g: groupId,
14412
15105
  host: s.entryKey
14413
15106
  };
14414
- const hubs = doorHubs(live.beacon, now, s.myKey);
15107
+ const hubs = doorHubs(this.hubs(live), now, s.myKey);
14415
15108
  if (!hubs.includes(s.myKey)) return;
14416
15109
  const door = [...hubs].sort()[0], doorSig = [...hubs].sort().join(",");
14417
15110
  if (live.doors !== doorSig) {
@@ -14433,7 +15126,7 @@ var Communities = class {
14433
15126
  ts
14434
15127
  }));
14435
15128
  for (const { key, ts } of knocks) {
14436
- if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host) continue;
15129
+ if (now - ts > 18e4 || this.refused.has(key) || live.pendingEntries.has(key) || key === link.host || key === s.myKey) continue;
14437
15130
  const stillKnocking = now - ts < 2 * this.timings.slowKnockMs;
14438
15131
  if (rosterHas(s.roster, key)) {
14439
15132
  const added = [...s.state.chain].reverse().find((c) => c.k === "add" && c.s === key)?.ts ?? 0;
@@ -14510,14 +15203,23 @@ var Communities = class {
14510
15203
  ms: Date.now() - started,
14511
15204
  record: n
14512
15205
  });
14513
- if (!this.knocked.has(group.id)) {
15206
+ const others = knocks.filter((k) => k.key !== me && now - k.ts < 2 * this.timings.slowKnockMs).length;
15207
+ const moved = (this.knockingWith.get(group.id) ?? 0) !== others;
15208
+ this.knockingWith.set(group.id, others);
15209
+ if (!this.knocked.has(group.id) || moved) {
14514
15210
  this.knocked.add(group.id);
14515
15211
  this.host.emit();
14516
15212
  }
14517
15213
  }
15214
+ /**
15215
+ * The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
15216
+ * link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
15217
+ * its next tick (it stops being a hub and a door there), and knocked for good with nothing for a member to answer on.
15218
+ */
14518
15219
  async closeEntries(groupId) {
14519
- this.live.get(groupId)?.pendingEntries.clear();
14520
- for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
15220
+ const live = this.live.get(groupId), mine = this.stored.get(groupId)?.joining?.linkId;
15221
+ live?.pendingEntries.clear();
15222
+ for (const linkId of this.host.entries(groupId).values()) if (linkId !== mine) await this.host.closeEdge(linkId);
14521
15223
  }
14522
15224
  /** An entry session of a community came up with groups v2 on both sides: the hub invites. */
14523
15225
  entryReady(groupId, linkId, peer) {
@@ -14602,7 +15304,16 @@ var Communities = class {
14602
15304
  return;
14603
15305
  }
14604
15306
  traceJoin(g, "welcome.received");
14605
- const again = !!this.live.get(g);
15307
+ const before = this.live.get(g)?.session, again = !!before;
15308
+ if (before?.status === "active" && before.isMember) {
15309
+ const mine = (chain) => [...chain].reverse().find((c) => c.k === "add" && c.s === before.myKey)?.sig;
15310
+ if (mine(joined.state.chain) === mine(before.state.chain)) {
15311
+ await this.endCheck(group);
15312
+ return;
15313
+ }
15314
+ await this.event(g, "removed", "You were removed from this group", this.now(), joined.state.chain.length - 1);
15315
+ }
15316
+ const back = !!before && before.status !== "lost";
14606
15317
  for (const [, id] of this.host.entries(g)) if (id !== linkId) await this.host.closeEdge(id);
14607
15318
  const member = {
14608
15319
  id: g,
@@ -14618,11 +15329,19 @@ var Communities = class {
14618
15329
  live.expect.add(joining.inviter);
14619
15330
  live.lobbyWrites.set(joining.inviter, now);
14620
15331
  live.joinedAt = now;
15332
+ const admission = [...joined.state.chain].reverse().find((c) => c.k === "add" && c.s === live.session.myKey);
15333
+ if (admission?.by === joining.inviter) live.hubClocks.set(joining.inviter, {
15334
+ said: 0,
15335
+ ahead: now - admission.ts,
15336
+ at: now
15337
+ });
14621
15338
  await this.store.putGroup(member);
14622
15339
  if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
15340
+ else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
14623
15341
  this.lastKnock.delete(g);
14624
15342
  this.knocked.delete(g);
14625
15343
  this.knockAt.delete(g);
15344
+ this.knockingWith.delete(g);
14626
15345
  await this.host.closeEdge(linkId);
14627
15346
  await this.reconcile(g, live, now);
14628
15347
  this.host.emit();
@@ -14642,12 +15361,17 @@ var Communities = class {
14642
15361
  /** A frame on an edge, from the member it is pinned to: handled, then relayed on by a hub. */
14643
15362
  async handleEdgeFrame(groupId, peerKey, frame) {
14644
15363
  const live = this.live.get(groupId);
14645
- if (!live || !frame || typeof frame !== "object" || live.session.wasRemoved(peerKey)) return;
15364
+ if (!live || !frame || typeof frame !== "object") return;
14646
15365
  const f = frame;
15366
+ if (live.session.wasRemoved(peerKey)) {
15367
+ if (f.t === "group-sync" && live.session.isMember) this.sayFarewell(groupId, live, peerKey, f);
15368
+ return;
15369
+ }
14647
15370
  if ((f.t === "group-secret" || f.t === "group-entry") && typeof f.to === "string" && f.to !== live.session.myKey) {
14648
15371
  if (!live.hub) return;
14649
15372
  const id = `${String(f.t)}:${f.to}:${String(f.h ?? f.x)}`;
14650
15373
  if (!this.remember(live, id)) return;
15374
+ if (live.session.wasRemoved(f.to)) return;
14651
15375
  const edges = this.host.edges(groupId), direct = edges.get(f.to);
14652
15376
  if (direct && this.host.linkReady(direct, 2)) {
14653
15377
  this.sendTo(direct, f);
@@ -14657,10 +15381,10 @@ var Communities = class {
14657
15381
  return;
14658
15382
  }
14659
15383
  if (!await live.session.handle(peerKey, frame) || !live.hub) return;
14660
- for (const [key, id] of this.host.edges(groupId)) if (key !== peerKey) this.sendTo(id, f);
15384
+ for (const [key, id] of this.hearers(groupId, f)) if (key !== peerKey) this.sendTo(id, f);
14661
15385
  }
14662
15386
  peerIsHub(live, key) {
14663
- return this.recentHub(live, key) && !live.members.has(key);
15387
+ return this.recentHub(live, key) && !live.members.has(key) && !live.session.wasRemoved(key);
14664
15388
  }
14665
15389
  remember(live, id) {
14666
15390
  if (live.relayed.has(id)) return false;
@@ -14668,14 +15392,16 @@ var Communities = class {
14668
15392
  if (live.relayed.size > RELAYED_KEPT) live.relayed.delete(live.relayed.values().next().value);
14669
15393
  return true;
14670
15394
  }
15395
+ /** True when the edge took the frame. */
14671
15396
  sendTo(linkId, frame) {
14672
- if (!linkId || !this.host.linkReady(linkId, 2)) return;
15397
+ if (!linkId || !this.host.linkReady(linkId, 2)) return false;
14673
15398
  try {
14674
15399
  this.host.sendOnLink(linkId, frame);
14675
15400
  } catch {
14676
- return;
15401
+ return false;
14677
15402
  }
14678
15403
  this.noteTaken(frame);
15404
+ return true;
14679
15405
  }
14680
15406
  /** My frame went out on an edge (at first, or in a catch-up): the message it is, or the edit it carries, was taken. */
14681
15407
  noteTaken(frame) {
@@ -14710,22 +15436,23 @@ var Communities = class {
14710
15436
  group.community = next;
14711
15437
  await this.store.putGroup(group);
14712
15438
  },
14713
- broadcast: (frame) => {
14714
- for (const linkId of this.host.edges(id).values()) this.sendTo(linkId, frame);
15439
+ broadcast: (frame) => this.hearers(id, frame).filter(([, linkId]) => this.sendTo(linkId, frame)).length,
15440
+ direct: (to, frame) => {
15441
+ if (this.mayHear(this.live.get(id), to, frame)) this.sendTo(this.host.edges(id).get(to), frame);
14715
15442
  },
14716
- direct: (to, frame) => this.sendTo(this.host.edges(id).get(to), frame),
14717
15443
  addressed: (to, frame) => {
14718
15444
  const edges = this.host.edges(id), live = this.live.get(id);
14719
15445
  const direct = edges.get(to);
15446
+ if (live?.session.wasRemoved(to)) return;
14720
15447
  if (direct && this.host.linkReady(direct, 2)) {
14721
15448
  this.sendTo(direct, frame);
14722
15449
  return;
14723
15450
  }
14724
15451
  const hubs = new Set(live ? [...live.seenHubs.keys()].filter((key) => this.recentHub(live, key)) : []);
14725
- for (const [key, linkId] of edges) if (hubs.has(key) || live?.myHubs.includes(key)) this.sendTo(linkId, frame);
15452
+ for (const [key, linkId] of edges) if ((hubs.has(key) || live?.myHubs.includes(key)) && !live?.session.wasRemoved(key)) this.sendTo(linkId, frame);
14726
15453
  },
14727
15454
  message: async (m) => {
14728
- const timestamp = receivedTimestamp(m.timestamp);
15455
+ const timestamp = m.timestamp;
14729
15456
  const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
14730
15457
  const stored = await this.host.storeMessage({
14731
15458
  linkId: MESSAGE_LINK$1(id),
@@ -14740,26 +15467,28 @@ var Communities = class {
14740
15467
  ...m.forwarded && { forwarded: m.forwarded },
14741
15468
  ...m.card && { card: m.card }
14742
15469
  });
14743
- this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, timestamp));
14744
- const came = cameAt(timestamp, stored, this.now());
15470
+ const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
15471
+ this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
14745
15472
  if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
14746
15473
  if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
15474
+ noteCame(this.stored.get(id), came, m.sender !== session.myKey, mentioned);
14747
15475
  },
14748
15476
  app: (m) => this.deliver(id, () => this.host.communityApp?.(id, m.sender, m.frame)),
14749
15477
  pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
14750
15478
  changed: () => {
14751
15479
  this.membershipChanged(id);
14752
15480
  },
14753
- metaChanged: (by, change) => {
15481
+ metaChanged: (by, change, at) => {
14754
15482
  const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
15483
+ const when = receivedTimestamp(at, this.now());
14755
15484
  (async () => {
14756
- for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, this.now(), session.epoch, by);
15485
+ for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
14757
15486
  this.host.emit();
14758
15487
  })();
14759
15488
  },
14760
15489
  clock: () => this.now(),
14761
15490
  relay: (frame) => {
14762
- if (this.live.get(id)?.hub) for (const linkId of this.host.edges(id).values()) this.sendTo(linkId, frame);
15491
+ if (this.live.get(id)?.hub) for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
14763
15492
  }
14764
15493
  });
14765
15494
  this.live.set(id, {
@@ -14767,8 +15496,15 @@ var Communities = class {
14767
15496
  hub: false,
14768
15497
  hubSince: 0,
14769
15498
  beacon: [],
15499
+ hubClocks: /* @__PURE__ */ new Map(),
15500
+ beaconSeenAt: 0,
14770
15501
  head: null,
14771
15502
  lastBeaconRead: 0,
15503
+ beaconKnown: false,
15504
+ beaconFailedAt: 0,
15505
+ beaconAt: 0,
15506
+ leaving: 0,
15507
+ stepDownAt: 0,
14772
15508
  lastBeaconWrite: 0,
14773
15509
  lastBeaconTry: 0,
14774
15510
  hubCandidateAt: 0,
@@ -14786,6 +15522,9 @@ var Communities = class {
14786
15522
  lingering: /* @__PURE__ */ new Map(),
14787
15523
  seenHubs: /* @__PURE__ */ new Map(),
14788
15524
  restored: /* @__PURE__ */ new Map(),
15525
+ farewells: /* @__PURE__ */ new Map(),
15526
+ farewellQuiet: /* @__PURE__ */ new Map(),
15527
+ farewellSaid: /* @__PURE__ */ new Map(),
14789
15528
  knocksScanned: false,
14790
15529
  knockCursor: 0,
14791
15530
  lastShardPoll: 0,
@@ -14814,22 +15553,17 @@ var Communities = class {
14814
15553
  if (statusChanged && s.status === "removed") await this.event(groupId, "removed", s.state.statusReason ?? "You were removed from this group", when, s.epoch);
14815
15554
  else if (statusChanged && s.status === "forked") await this.event(groupId, "forked", s.state.statusReason ?? "The membership history forked", when, s.epoch);
14816
15555
  else if (s.status === "active" && before !== after) {
14817
- for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, when, top.e, key);
14818
- for (const [key] of before) if (!rosterHas(after, key)) await this.event(groupId, "gone", `${name(key)} is no longer a member`, when, top.e, key);
14819
- if (top.k === "role" && before.find(([k]) => k === top.s)?.[1] !== "admin") await this.event(groupId, "admin", `${name(top.s)} ${top.s === s.myKey ? "are" : "is"} now the admin`, when, top.e, top.s);
14820
- if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", when, top.e);
15556
+ const at = receivedTimestamp(top.ts, when);
15557
+ for (const [key] of after) if (!rosterHas(before, key) && key !== s.myKey) await this.event(groupId, "joined", `${name(key)} joined`, at, top.e, key);
15558
+ for (const [key] of before) if (!rosterHas(after, key)) await this.event(groupId, "gone", `${name(key)} is no longer a member`, at, top.e, key);
15559
+ if (top.k === "role" && before.find(([k]) => k === top.s)?.[1] !== "admin") await this.event(groupId, "admin", `${name(top.s)} ${top.s === s.myKey ? "are" : "is"} now the admin`, at, top.e, top.s);
15560
+ if (top.k === "rotate") await this.event(groupId, "rotated", "Keys rotated: a fresh epoch", at, top.e);
14821
15561
  }
14822
15562
  this.host.emit();
14823
15563
  }
14824
- /**
14825
- * A line in the group's history. Its id carries its time, and the engine's clock moves once a tick: two
14826
- * lines of one kind within a tick (a picture changed, then removed) get distinct times, a millisecond
14827
- * apart, or the store would keep the first and drop the second as already there.
14828
- */
15564
+ /** A line in the group's history. Its id carries its time: two lines at the same moment get distinct ones (`eventTime`). */
14829
15565
  async event(groupId, event, text, timestamp, epoch, member) {
14830
- const last = this.lastEventAt.get(groupId) ?? 0;
14831
- if (timestamp <= last) timestamp = last + 1;
14832
- this.lastEventAt.set(groupId, Math.max(last, timestamp));
15566
+ timestamp = eventTime(this.eventTimes, groupId, timestamp);
14833
15567
  await this.host.storeMessage({
14834
15568
  linkId: MESSAGE_LINK$1(groupId),
14835
15569
  id: `event:${epoch}:${event}:${member ?? ""}:${timestamp}`,
@@ -14841,7 +15575,7 @@ var Communities = class {
14841
15575
  via: "datalink"
14842
15576
  });
14843
15577
  }
14844
- lastEventAt = /* @__PURE__ */ new Map();
15578
+ eventTimes = /* @__PURE__ */ new Map();
14845
15579
  };
14846
15580
  //#endregion
14847
15581
  //#region ../browser/src/engine/meshHubs.ts
@@ -15256,7 +15990,7 @@ var MeshHubs = class {
15256
15990
  ...legacy.length ? { l: legacy } : {}
15257
15991
  };
15258
15992
  for (const [key, id] of edges) {
15259
- if (only && key !== only || !this.host.linkReady(id)) continue;
15993
+ if (only && key !== only || !rosterHas(session.roster, key) || !this.host.linkReady(id)) continue;
15260
15994
  try {
15261
15995
  this.host.sendOnLink(id, frame);
15262
15996
  } catch {}
@@ -15350,8 +16084,9 @@ var MeshHubs = class {
15350
16084
  for (const [key, id] of this.host.edges(groupId)) {
15351
16085
  if (key === from || !this.host.linkReady(id)) continue;
15352
16086
  const member = rosterHas(session.roster, key);
16087
+ const until = member ? Infinity : removalEpoch(session.state.chain, key);
15353
16088
  for (const frame of frames) {
15354
- if (authorOf(frame) === key || !member && frame.t !== "group-commit") continue;
16089
+ if (authorOf(frame) === key || !member && (frame.t !== "group-commit" || frame.commit.e > until)) continue;
15355
16090
  try {
15356
16091
  this.host.sendOnLink(id, frame);
15357
16092
  sent++;
@@ -15363,6 +16098,14 @@ var MeshHubs = class {
15363
16098
  return sent;
15364
16099
  }
15365
16100
  };
16101
+ /**
16102
+ * The epoch of the commit that took `key` out of the group for the last time: the one after the last roster it was
16103
+ * in. -1 for a key the chain never had. Someone out of the roster is sent no commit past it.
16104
+ */
16105
+ function removalEpoch(chain, key) {
16106
+ for (let e = chain.length - 1; e >= 0; e--) if (rosterHas(chain[e].m, key)) return e + 1;
16107
+ return -1;
16108
+ }
15366
16109
  function authorOf(frame) {
15367
16110
  switch (frame.t) {
15368
16111
  case "group-msg":
@@ -15450,7 +16193,7 @@ var GroupTypings = class {
15450
16193
  * knocking, and stayed "invited" for 10 minutes (2026-09-30).
15451
16194
  */
15452
16195
  function otherEndSeen(presence, dataLink, now = Date.now()) {
15453
- return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presence.lastPacketAt < 3e4;
16196
+ return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presenceSeenAt(presence) < 3e4;
15454
16197
  }
15455
16198
  /** The key an edit of message `id` goes by in `FramesTaken`. */
15456
16199
  var editKey = (id, e) => `${id}#e${e}`;
@@ -15481,12 +16224,53 @@ var mentionAt = (map, groupId) => {
15481
16224
  return at ? { lastMentionAt: at } : {};
15482
16225
  };
15483
16226
  /**
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.
16227
+ * When a message counts as having come, for the list of groups, unread and the "@": when it reached me, never when
16228
+ * its sender says it was written (a member's clock ahead would keep the group unread and on top; written before I
16229
+ * last looked but handed to me since, a catch-up is news here). A copy I had already (`stored` false) or one
16230
+ * completed in place moves nothing forward: its own time, now at the latest.
16231
+ */
16232
+ var cameAt = (timestamp, stored, now) => stored === false ? Math.min(timestamp, now) : now;
16233
+ /**
16234
+ * The time a line of a group's history is kept under: its own, or the next millisecond no other line of this run took.
16235
+ * A line's id carries its time, and two lines can share a moment (two members gone in one change, a picture changed
16236
+ * then removed within a tick): the store would keep the first and drop the second as already there. Only a time
16237
+ * already taken moves: a line older than the last one written (a rename read after a later commit, in a catch-up)
16238
+ * keeps its place.
16239
+ */
16240
+ function eventTime(taken, groupId, timestamp) {
16241
+ let times = taken.get(groupId);
16242
+ if (!times) taken.set(groupId, times = /* @__PURE__ */ new Set());
16243
+ while (times.has(timestamp)) timestamp++;
16244
+ times.add(timestamp);
16245
+ if (times.size > 1024) times.delete(times.values().next().value);
16246
+ return timestamp;
16247
+ }
16248
+ /**
16249
+ * The moment something arrives, for the unread marks: this device's clock, never behind it. The engine's own clock is
16250
+ * its last tick's time, up to a second old, while the page marks a group read by the device's clock as it is looked at:
16251
+ * a message that came within that second of leaving the group counted as come before it was read, and the list showed
16252
+ * nothing new. (A simulation's clock runs ahead of the device's: it stands.)
16253
+ */
16254
+ var arrivalNow = (tick) => Math.max(tick, Date.now());
16255
+ /**
16256
+ * Keeps on the group's record when a member's message came, where that is later than what it holds: the record is
16257
+ * saved with the session right after (a message taken moves its state), so a message handed to me late still makes
16258
+ * the group unread after the app starts again. Read from the history alone, by its own time, it sorted among what I
16259
+ * had read and the mark was gone on the next start.
15488
16260
  */
15489
- var cameAt = (timestamp, stored, now) => stored === false ? timestamp : Math.max(timestamp, now);
16261
+ function noteCame(group, came, peer, mentioned) {
16262
+ if (!group) return;
16263
+ if (peer && came > (group.came?.peer ?? 0)) group.came = {
16264
+ ...group.came,
16265
+ peer: came
16266
+ };
16267
+ if (mentioned && came > (group.came?.mention ?? 0)) group.came = {
16268
+ ...group.came,
16269
+ mention: came
16270
+ };
16271
+ }
16272
+ /** What `load` starts the unread marks from: the history's own times, or when its latest came if that was later. */
16273
+ var cameOrWritten = (written, came) => written === void 0 && came === void 0 ? void 0 : Math.max(written ?? 0, came ?? 0);
15490
16274
  /** The view's `lastPeerMessageAt`, when there is one. */
15491
16275
  var peerMessageAt = (map, groupId) => {
15492
16276
  const at = map.get(groupId);
@@ -15500,6 +16284,7 @@ var ENTRY_TIMINGS = {
15500
16284
  slowKnockMs: 2e4,
15501
16285
  patienceMs: 12e4
15502
16286
  };
16287
+ var KNOCK_VERIFY_SPREAD_MS = 700;
15503
16288
  /** Entry sessions the admin runs at once; a joiner who does not finish in time is not answered again for a while. */
15504
16289
  var MAX_PENDING_ENTRIES = 4;
15505
16290
  var ENTRY_TIMEOUT_MS = 18e4;
@@ -15510,12 +16295,19 @@ var REFUSED_FOR_MS = 6e5;
15510
16295
  * doubling with each timeout in a row up to `REFUSED_FOR_MS`: a slow network is no refusal.
15511
16296
  */
15512
16297
  var ENTRY_RETRY_MS = 3e4;
15513
- /** The welcome is on its way when the admin sends it; the session stays up a little for it to arrive. */
16298
+ /**
16299
+ * The welcome is on its way when the admin sends it; the session stays up a little for it to arrive. A joiner keeps
16300
+ * its side as long (unless its edge to the admin is up first): the admin says over it what it commits meanwhile.
16301
+ */
15514
16302
  var ENTRY_LINGER_MS = 2e4;
15515
16303
  /** Taken out of a group, my edges stay this long: as a hub, the commit that removed me is passed on over them. */
15516
16304
  var REMOVED_LINGER_MS = 15e3;
15517
16305
  /** How long the tombstone of a group I left waits for the admin to hear it. */
15518
16306
  var LEFT_KEPT_MS = 6048e5;
16307
+ /** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
16308
+ var FAREWELL_ANSWERS = 3;
16309
+ /** Commits sent to a removed member that was behind when it left, at most. */
16310
+ var FAREWELL_COMMITS = 64;
15519
16311
  /** Where a member's gossip turns start among the members it is connected to: a number of its own, from its key. */
15520
16312
  var gossipStart = (key) => {
15521
16313
  let n = 0;
@@ -15594,8 +16386,21 @@ var Groups = class {
15594
16386
  /** Admin side: until when a group's link is looked at the warm pace. */
15595
16387
  warmUntil = /* @__PURE__ */ new Map();
15596
16388
  lastKnock = /* @__PURE__ */ new Map();
16389
+ /** Joiner side: when a knock just written is read back (`KNOCK_VERIFY_MS`); none once it was found there. */
16390
+ knockCheckAt = /* @__PURE__ */ new Map();
16391
+ /** Joiner side: knocks written in a row without one found in the record since (`KNOCK_VERIFIES`). */
16392
+ knockRewrites = /* @__PURE__ */ new Map();
15597
16393
  /** Per group, members met over their admission a moment ago: their edge is opened expecting them. */
15598
16394
  justMet = /* @__PURE__ */ new Map();
16395
+ /**
16396
+ * Admin side: joiners let in through the link whose entry session is still open, per group: member key → link id.
16397
+ * Until the edge to one of them is up, a commit made meanwhile (the next joiner's admission) goes to it over that
16398
+ * session: it would otherwise seal what it writes for a roster that is no longer the group's, and whoever was let
16399
+ * in since could never read it (two people open the link together, both say hello, one never sees the other's).
16400
+ */
16401
+ welcomed = /* @__PURE__ */ new Map();
16402
+ /** Joiner side: the entry session I was let in through, kept until my edge to the admin is up or `until`. */
16403
+ lingering = /* @__PURE__ */ new Map();
15599
16404
  /** Joiner side: groups whose knock is published, for the stage the joiner is shown. */
15600
16405
  knocked = /* @__PURE__ */ new Set();
15601
16406
  /**
@@ -15651,9 +16456,11 @@ var Groups = class {
15651
16456
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
15652
16457
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
15653
16458
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
15654
- if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
16459
+ const peerAt = cameOrWritten(lastPeer?.timestamp, lastPeer ? group.came?.peer : void 0);
16460
+ if (peerAt) this.lastPeerMessageAt.set(group.id, peerAt);
15655
16461
  const mention = [...history].reverse().find((m) => m.mentioned);
15656
- if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
16462
+ const mentionAt = cameOrWritten(mention?.timestamp, mention ? group.came?.mention : void 0);
16463
+ if (mentionAt) this.lastMentionAt.set(group.id, mentionAt);
15657
16464
  }
15658
16465
  for (const id of this.sessions.keys()) this.reconcileEdges(id);
15659
16466
  for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
@@ -15957,6 +16764,8 @@ var Groups = class {
15957
16764
  delete group.entry;
15958
16765
  this.invited.delete(groupId);
15959
16766
  this.pendingEntries.delete(groupId);
16767
+ this.welcomed.delete(groupId);
16768
+ this.lingering.delete(groupId);
15960
16769
  this.lastMessageAt.delete(groupId);
15961
16770
  this.lastPeerMessageAt.delete(groupId);
15962
16771
  this.lastMentionAt.delete(groupId);
@@ -15989,25 +16798,138 @@ var Groups = class {
15989
16798
  }
15990
16799
  async remove(groupId, key) {
15991
16800
  if (this.isCommunity(groupId)) return this.communities.remove(groupId, key);
15992
- await this.session(groupId).remove(key);
15993
- const contact = this.stored.get(groupId).contacts?.[key];
16801
+ const session = this.session(groupId), group = this.stored.get(groupId);
16802
+ const edge = this.host.edges(groupId).get(key), edgeUp = !!edge && this.host.linkReady(edge);
16803
+ const noted = !edgeUp && session.isAdmin && rosterHas(session.roster, key) && key !== session.myKey;
16804
+ if (noted) {
16805
+ this.noteFarewell(group, key, session.epoch + 1);
16806
+ if (edge && this.host.linkOpen?.(edge)) group.farewells[key].opened = this.now();
16807
+ }
16808
+ try {
16809
+ await session.remove(key);
16810
+ } catch (error) {
16811
+ if (noted) this.dropFarewell(group, key);
16812
+ throw error;
16813
+ }
16814
+ const contact = group.contacts?.[key];
16815
+ let told = edgeUp;
15994
16816
  if (contact) try {
15995
16817
  this.host.sendOnLink(contact, {
15996
16818
  t: "group-removed",
15997
16819
  g: groupId
15998
16820
  });
16821
+ told = true;
15999
16822
  } catch {}
16823
+ if (noted && told) this.dropFarewell(group, key);
16824
+ if (noted) {
16825
+ await this.store.putGroup(group);
16826
+ this.reconcileEdges(groupId);
16827
+ }
16000
16828
  }
16001
- makeAdmin(groupId, key) {
16002
- return this.isCommunity(groupId) ? this.communities.makeAdmin(groupId, key) : this.session(groupId).transferAdmin(key);
16829
+ /** A member removed while it cannot be told: its edge stays until it opens (see `StoredGroup.farewells`). The oldest go past `MAX_FAREWELLS`. */
16830
+ noteFarewell(group, key, e) {
16831
+ const kept = Object.entries(group.farewells ?? {}).filter(([k]) => k !== key).sort(([, a], [, b]) => b.at - a.at).slice(0, 7);
16832
+ group.farewells = Object.fromEntries([...kept, [key, {
16833
+ at: this.now(),
16834
+ e
16835
+ }]]);
16003
16836
  }
16004
- /**
16005
- * The admin pins a member of a private group as a hub, excludes one from being a hub, or leaves it to the member's
16006
- * app (`null`): said in the group's metadata, which every member keeps (WISP 9xx · Group Mesh § Hubs).
16007
- */
16008
- async setHub(groupId, key, role) {
16009
- if (this.isCommunity(groupId)) throw new Error("A community group chooses its hubs by itself");
16010
- await this.session(groupId).setHub(key, role);
16837
+ dropFarewell(group, key) {
16838
+ if (!group.farewells?.[key]) return;
16839
+ const { [key]: _gone, ...rest } = group.farewells;
16840
+ group.farewells = Object.keys(rest).length ? rest : void 0;
16841
+ }
16842
+ /** The removed members this admin still waits for: not told yet, or told a moment ago (the commit is on its way). */
16843
+ farewellKeys(group, session, now) {
16844
+ if (!group?.farewells || session.status !== "active") return [];
16845
+ return Object.entries(group.farewells).filter(([key, f]) => !rosterHas(session.roster, key) && now - f.at <= 6048e5 && (f.told === void 0 || now - f.told < REMOVED_LINGER_MS) && (f.opened === void 0 || now - f.opened < 6e4)).map(([key]) => key);
16846
+ }
16847
+ /** The edges an active member keeps: what the roster (or the hubs) ask for, and those to members it removed while they were away. */
16848
+ edgesWanted(groupId, session, group, now) {
16849
+ const wanted = this.hubs.edgesWanted(groupId, session, group, now), farewells = this.farewellKeys(group, session, now);
16850
+ return farewells.length ? /* @__PURE__ */ new Set([...wanted, ...farewells]) : wanted;
16851
+ }
16852
+ /** A member key in the roster of a private group I am active in (a community: its own engine answers). */
16853
+ inRoster(groupId, key) {
16854
+ const session = this.sessions.get(groupId);
16855
+ return !!session && session.status === "active" && rosterHas(session.roster, key);
16856
+ }
16857
+ /** A key out of the roster of a private group I am active in: a former member, or a stranger. */
16858
+ outsider(groupId, key) {
16859
+ const session = this.sessions.get(groupId);
16860
+ return !!session && session.status === "active" && !rosterHas(session.roster, key);
16861
+ }
16862
+ /**
16863
+ * Whether a frame may go over a private group's edge to `key`. To a member, anything. To someone out of the roster
16864
+ * of a group I am active in, only the commits up to the one that took it out: an edge to a former member exists a
16865
+ * moment (its last commit is on its way) or is kept to tell it (`StoredGroup.farewells`), and carries nothing else.
16866
+ */
16867
+ edgeAllows(groupId, key, frame) {
16868
+ const session = this.sessions.get(groupId);
16869
+ if (!session || session.status !== "active" || rosterHas(session.roster, key)) return true;
16870
+ const commit = frame && typeof frame === "object" && frame.t === "group-commit" ? frame.commit : void 0;
16871
+ return typeof commit?.e === "number" && commit.e <= removalEpoch(session.state.chain, key);
16872
+ }
16873
+ /** The connection of an edge opened. One kept to tell a removed member closes a while after, whatever its app says or does not say. */
16874
+ edgeOpen(groupId, peerKey) {
16875
+ const group = this.stored.get(groupId), farewell = group?.farewells?.[peerKey];
16876
+ if (!group || !farewell || farewell.opened !== void 0) return;
16877
+ farewell.opened = this.now();
16878
+ this.store.putGroup(group).catch(() => {});
16879
+ }
16880
+ /**
16881
+ * The edge to a member I removed while it was away came up: the commit that removed it goes now, which is all this
16882
+ * edge carries. Its app applies it when it had followed the chain to the commit before; one further behind says
16883
+ * where it is in its sync, and gets the commits between (`farewellSync`). No secret goes with any of them.
16884
+ */
16885
+ sayFarewell(groupId, session, group, key, linkId) {
16886
+ const farewell = group.farewells[key], commit = session.state.chain[farewell.e];
16887
+ if (!commit) {
16888
+ this.dropFarewell(group, key);
16889
+ return;
16890
+ }
16891
+ try {
16892
+ this.host.sendOnLink(linkId, {
16893
+ t: "group-commit",
16894
+ g: groupId,
16895
+ commit
16896
+ });
16897
+ } catch {
16898
+ return;
16899
+ }
16900
+ farewell.told ??= this.now();
16901
+ this.store.putGroup(group).catch(() => {});
16902
+ }
16903
+ /** The sync of a removed member I wait for: the commits it lacks up to the one that removed it, a few times at most. */
16904
+ farewellSync(groupId, session, group, key, frame) {
16905
+ const farewell = group.farewells[key], linkId = this.host.edges(groupId).get(key);
16906
+ const from = frame.e;
16907
+ if (!linkId || frame.t !== "group-sync" || !Number.isSafeInteger(from) || from < 0 || from >= farewell.e - 1) return;
16908
+ if ((farewell.answered ?? 0) >= FAREWELL_ANSWERS) return;
16909
+ farewell.answered = (farewell.answered ?? 0) + 1;
16910
+ try {
16911
+ for (let e = Math.max(from + 1, farewell.e - FAREWELL_COMMITS); e <= farewell.e; e++) {
16912
+ const commit = session.state.chain[e];
16913
+ if (commit) this.host.sendOnLink(linkId, {
16914
+ t: "group-commit",
16915
+ g: groupId,
16916
+ commit
16917
+ });
16918
+ }
16919
+ } catch {}
16920
+ farewell.told ??= this.now();
16921
+ this.store.putGroup(group).catch(() => {});
16922
+ }
16923
+ makeAdmin(groupId, key) {
16924
+ return this.isCommunity(groupId) ? this.communities.makeAdmin(groupId, key) : this.session(groupId).transferAdmin(key);
16925
+ }
16926
+ /**
16927
+ * The admin pins a member of a private group as a hub, excludes one from being a hub, or leaves it to the member's
16928
+ * app (`null`): said in the group's metadata, which every member keeps (WISP 9xx · Group Mesh § Hubs).
16929
+ */
16930
+ async setHub(groupId, key, role) {
16931
+ if (this.isCommunity(groupId)) throw new Error("A community group chooses its hubs by itself");
16932
+ await this.session(groupId).setHub(key, role);
16011
16933
  this.host.emit();
16012
16934
  }
16013
16935
  rotate(groupId) {
@@ -16035,7 +16957,11 @@ var Groups = class {
16035
16957
  this.lastTold.delete(groupId);
16036
16958
  this.pendingEntries.delete(groupId);
16037
16959
  this.knocked.delete(groupId);
16960
+ this.knockCheckAt.delete(groupId);
16961
+ this.knockRewrites.delete(groupId);
16038
16962
  this.justMet.delete(groupId);
16963
+ this.welcomed.delete(groupId);
16964
+ this.lingering.delete(groupId);
16039
16965
  this.relayAsked.delete(groupId);
16040
16966
  this.hubs.forget(groupId);
16041
16967
  this.removedAt.delete(groupId);
@@ -16103,7 +17029,7 @@ var Groups = class {
16103
17029
  if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
16104
17030
  if (existing?.invitation?.seedB64 && !existing.invitation.entry) throw new Error("You are already joining this group");
16105
17031
  if (existing) await this.forget(link.g);
16106
- const seedB64 = createIdentity().seedB64;
17032
+ const seedB64 = dialedKey(link);
16107
17033
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
16108
17034
  const group = {
16109
17035
  id: link.g,
@@ -16138,7 +17064,7 @@ var Groups = class {
16138
17064
  }
16139
17065
  if (group.invitation?.entry && !group.state) {
16140
17066
  const every = now - group.createdAt > this.timings.patienceMs ? this.timings.slowKnockMs : this.timings.knockMs;
16141
- if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && now - (this.lastKnock.get(group.id) ?? 0) >= every) await this.knock(group, now).catch(() => {});
17067
+ if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && (now - (this.lastKnock.get(group.id) ?? 0) >= every || now >= (this.knockCheckAt.get(group.id) ?? Infinity))) await this.knock(group, now).catch(() => {});
16142
17068
  continue;
16143
17069
  }
16144
17070
  const session = this.sessions.get(group.id);
@@ -16167,6 +17093,7 @@ var Groups = class {
16167
17093
  }
16168
17094
  for (const [key, until] of this.refused) if (until <= now) this.refused.delete(key);
16169
17095
  for (const [key, { at }] of this.entryTimeouts) if (now - at > 36e5) this.entryTimeouts.delete(key);
17096
+ for (const [groupId, kept] of this.lingering) if (now >= kept.until) await this.endLinger(groupId);
16170
17097
  this.meshTick(now);
16171
17098
  await this.communities.tick(now);
16172
17099
  await this.hubsTick(now);
@@ -16188,7 +17115,7 @@ var Groups = class {
16188
17115
  if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
16189
17116
  await this.hubs.tick(groupId, session, group, now).catch(() => false);
16190
17117
  if (this.hubReconcile.has(groupId)) continue;
16191
- const wanted = this.hubs.edgesWanted(groupId, session, group, now);
17118
+ const wanted = this.edgesWanted(groupId, session, group, now);
16192
17119
  const edges = this.host.edges(groupId);
16193
17120
  if ([...wanted].some((key) => !edges.has(key)) || [...edges.keys()].some((key) => !wanted.has(key))) {
16194
17121
  this.hubReconcile.add(groupId);
@@ -16235,8 +17162,16 @@ var Groups = class {
16235
17162
  seedB64: group.entry.seedB64
16236
17163
  } : void 0;
16237
17164
  }
17165
+ /** Joiner side: the entry session kept after the welcome goes (the edge to the admin is up, or it was kept long enough). */
17166
+ async endLinger(groupId) {
17167
+ const kept = this.lingering.get(groupId);
17168
+ if (!kept) return;
17169
+ this.lingering.delete(groupId);
17170
+ if ([...this.host.entries(groupId).values()].includes(kept.linkId)) await this.host.closeEdge(kept.linkId);
17171
+ }
16238
17172
  async closeEntries(groupId) {
16239
17173
  this.pendingEntries.delete(groupId);
17174
+ this.welcomed.delete(groupId);
16240
17175
  const invited = this.invited.get(groupId);
16241
17176
  for (const linkId of this.host.entries(groupId).values()) {
16242
17177
  invited?.delete(linkId);
@@ -16251,15 +17186,29 @@ var Groups = class {
16251
17186
  host: invitation.entry
16252
17187
  }, identity = knockIdentity(link);
16253
17188
  const started = Date.now();
17189
+ this.knockCheckAt.delete(group.id);
16254
17190
  const existing = readKnocks(link, await this.host.resolve(identity.pubKeyZ32) ?? []);
17191
+ const key = identityFromSeedB64(invitation.seedB64).pubKeyZ32, mine = existing.find((k) => k.key === key);
16255
17192
  traceJoin(group.id, "knock.read", {
16256
17193
  ms: Date.now() - started,
16257
- others: existing.length
17194
+ others: existing.length,
17195
+ ...mine ? { mine: now - mine.ts } : {}
16258
17196
  });
17197
+ if (mine && now - mine.ts >= 0 && now - mine.ts < 3e4) {
17198
+ this.knockRewrites.delete(group.id);
17199
+ if (!this.knocked.has(group.id)) {
17200
+ this.knocked.add(group.id);
17201
+ this.host.emit();
17202
+ }
17203
+ return;
17204
+ }
16259
17205
  await this.host.publish(identity, knockRecords(link, mergeKnocks(existing, {
16260
- key: identityFromSeedB64(invitation.seedB64).pubKeyZ32,
17206
+ key,
16261
17207
  ts: now
16262
17208
  }, now)));
17209
+ const rewrites = (this.knockRewrites.get(group.id) ?? 0) + 1;
17210
+ this.knockRewrites.set(group.id, rewrites);
17211
+ if (rewrites <= 2) this.knockCheckAt.set(group.id, now + 800 + gossipStart(key) % KNOCK_VERIFY_SPREAD_MS);
16263
17212
  traceJoin(group.id, "knock.published", { ms: Date.now() - started });
16264
17213
  if (!this.knocked.has(group.id)) {
16265
17214
  this.knocked.add(group.id);
@@ -16375,7 +17324,11 @@ var Groups = class {
16375
17324
  this.entryTimeouts.delete(entryPeer);
16376
17325
  this.pendingEntries.get(g)?.delete(entryPeer);
16377
17326
  this.host.entryDone?.(linkId);
17327
+ let welcomed = this.welcomed.get(g);
17328
+ if (!welcomed) this.welcomed.set(g, welcomed = /* @__PURE__ */ new Map());
17329
+ welcomed.set(entryPeer, linkId);
16378
17330
  setTimeout(() => {
17331
+ if (this.welcomed.get(g)?.get(entryPeer) === linkId) this.welcomed.get(g).delete(entryPeer);
16379
17332
  if (this.host.entries(g).get(entryPeer) === linkId) this.host.closeEdge(linkId);
16380
17333
  }, ENTRY_LINGER_MS);
16381
17334
  }
@@ -16423,6 +17376,11 @@ var Groups = class {
16423
17376
  };
16424
17377
  this.stored.set(g, member);
16425
17378
  this.attach(joined.state);
17379
+ if (viaLink) this.lingering.set(g, {
17380
+ linkId,
17381
+ admin: group.invitation.admin,
17382
+ until: this.now() + ENTRY_LINGER_MS
17383
+ });
16426
17384
  await this.store.putGroup(member);
16427
17385
  if (!viaLink) await this.sessions.get(g).setNick(group.invitation.admin, this.host.contactName(linkId));
16428
17386
  await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE}`, Date.now(), joined.state.chain.length - 1);
@@ -16430,12 +17388,18 @@ var Groups = class {
16430
17388
  if (viaLink) {
16431
17389
  this.lastKnock.delete(g);
16432
17390
  this.knocked.delete(g);
16433
- await this.host.closeEdge(linkId);
16434
17391
  }
16435
17392
  this.reconcileEdges(g);
16436
17393
  this.host.emit();
16437
17394
  return;
16438
17395
  }
17396
+ case "group-commit": {
17397
+ const kept = this.lingering.get(g), session = this.sessions.get(g);
17398
+ if (!kept || kept.linkId !== linkId || !session) return;
17399
+ const taken = await session.handle(kept.admin, frame);
17400
+ if (taken.length) this.hubs.passOn(g, session, kept.admin, taken);
17401
+ return;
17402
+ }
16439
17403
  case "group-removed": {
16440
17404
  const session = this.sessions.get(g);
16441
17405
  const group = this.stored.get(g);
@@ -16463,6 +17427,10 @@ var Groups = class {
16463
17427
  }
16464
17428
  }
16465
17429
  }
17430
+ /** A key the group's chain had as a member and has no more (not one it never had: a member my chain has not reached yet). */
17431
+ tookOut(session, key) {
17432
+ return !rosterHas(session.roster, key) && removalEpoch(session.state.chain, key) >= 0;
17433
+ }
16466
17434
  /** A `group-*` frame on an edge: from the member the edge is pinned to. */
16467
17435
  async handleEdgeFrame(groupId, peerKey, frame) {
16468
17436
  if (this.isCommunity(groupId)) return this.communities.handleEdgeFrame(groupId, peerKey, frame);
@@ -16471,6 +17439,12 @@ var Groups = class {
16471
17439
  return;
16472
17440
  }
16473
17441
  const session = this.sessions.get(groupId);
17442
+ const waiting = this.stored.get(groupId);
17443
+ if (session && waiting?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) {
17444
+ if (session.status === "active" && frame && typeof frame === "object") this.farewellSync(groupId, session, waiting, peerKey, frame);
17445
+ return;
17446
+ }
17447
+ if (session?.status === "active" && this.tookOut(session, peerKey)) return;
16474
17448
  if (session?.status === "active" && frame && typeof frame === "object" && frame.t === "group-here") {
16475
17449
  this.heardHere(groupId, session, peerKey, frame);
16476
17450
  return;
@@ -16559,6 +17533,10 @@ var Groups = class {
16559
17533
  return;
16560
17534
  }
16561
17535
  const session = this.sessions.get(groupId), group = this.stored.get(groupId);
17536
+ if (session?.status === "active" && group?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) {
17537
+ this.sayFarewell(groupId, session, group, peerKey, linkId);
17538
+ return;
17539
+ }
16562
17540
  if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
16563
17541
  const large = this.host.linkReady(linkId, 3) && this.markLarge(group, peerKey);
16564
17542
  const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
@@ -16568,6 +17546,8 @@ var Groups = class {
16568
17546
  } catch {
16569
17547
  return;
16570
17548
  }
17549
+ if (this.lingering.get(groupId)?.admin === peerKey) this.endLinger(groupId).catch(() => {});
17550
+ this.welcomed.get(groupId)?.delete(peerKey);
16571
17551
  this.announceHere(groupId, session, peerKey);
16572
17552
  this.host.edgeUp?.(groupId, peerKey);
16573
17553
  this.host.emit();
@@ -16687,6 +17667,7 @@ var Groups = class {
16687
17667
  if (reconcile) this.lastReconcile = now;
16688
17668
  for (const [groupId, session] of this.sessions) {
16689
17669
  if (session.status !== "active" || this.stored.get(groupId)?.left) continue;
17670
+ this.farewellsTick(groupId, session, now);
16690
17671
  if (reconcile) {
16691
17672
  const edges = this.host.edges(groupId);
16692
17673
  if (!this.hubs.large(session) && session.others.some((key) => !edges.has(key))) this.reconcileEdges(groupId);
@@ -16710,12 +17691,25 @@ var Groups = class {
16710
17691
  } catch {}
16711
17692
  }
16712
17693
  }
17694
+ /** A removed member told a moment ago, or waited for a week: its edge goes, and nothing more is kept about it. */
17695
+ farewellsTick(groupId, session, now) {
17696
+ const group = this.stored.get(groupId);
17697
+ if (!group?.farewells) return;
17698
+ const waiting = new Set(this.farewellKeys(group, session, now));
17699
+ const done = Object.keys(group.farewells).filter((key) => !waiting.has(key));
17700
+ if (!done.length) return;
17701
+ for (const key of done) this.dropFarewell(group, key);
17702
+ this.store.putGroup(group).catch(() => {});
17703
+ this.reconcileEdges(groupId);
17704
+ }
16713
17705
  edgeNick(groupId, peerKey, nick) {
16714
17706
  if (this.isCommunity(groupId)) {
16715
17707
  this.communities.edgeNick(groupId, peerKey, nick);
16716
17708
  return;
16717
17709
  }
16718
- this.sessions.get(groupId)?.setNick(peerKey, nick);
17710
+ const session = this.sessions.get(groupId);
17711
+ if (session && this.stored.get(groupId)?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) return;
17712
+ session?.setNick(peerKey, nick);
16719
17713
  }
16720
17714
  session(groupId) {
16721
17715
  const session = this.sessions.get(groupId);
@@ -16733,10 +17727,14 @@ var Groups = class {
16733
17727
  },
16734
17728
  send: (to, frame) => {
16735
17729
  const edge = this.host.edges(state.id).get(to);
16736
- if (!edge) return;
16737
17730
  try {
17731
+ if (!edge) throw new Error("no edge");
16738
17732
  this.host.sendOnLink(edge, frame);
16739
17733
  } catch {
17734
+ const entry = frame.t === "group-commit" ? this.welcomed.get(state.id)?.get(to) : void 0;
17735
+ if (entry) try {
17736
+ this.host.sendOnLink(entry, frame);
17737
+ } catch {}
16740
17738
  return;
16741
17739
  }
16742
17740
  if ((frame.t === "group-msg" || frame.t === "group-edit") && frame.s === session.myKey) {
@@ -16745,7 +17743,7 @@ var Groups = class {
16745
17743
  }
16746
17744
  },
16747
17745
  message: async (m) => {
16748
- const timestamp = receivedTimestamp(m.timestamp);
17746
+ const timestamp = m.timestamp;
16749
17747
  const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
16750
17748
  const message = {
16751
17749
  linkId: MESSAGE_LINK(state.id),
@@ -16761,10 +17759,11 @@ var Groups = class {
16761
17759
  ...m.card && { card: m.card }
16762
17760
  };
16763
17761
  const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
16764
- this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, timestamp));
16765
- const came = cameAt(timestamp, stored, this.now());
17762
+ const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
17763
+ this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
16766
17764
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
16767
17765
  if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
17766
+ noteCame(this.stored.get(state.id), came, m.sender !== session.myKey, mentioned);
16768
17767
  this.typings.messageFrom(state.id, m.sender);
16769
17768
  },
16770
17769
  edit: (e) => this.host.groupEdit?.(state.id, e),
@@ -16772,8 +17771,8 @@ var Groups = class {
16772
17771
  if (session.status === "removed" && this.hubs.isHub(state.id) && !this.removedAt.has(state.id)) this.removedAt.set(state.id, this.now());
16773
17772
  this.membershipChanged(state.id);
16774
17773
  },
16775
- metaChanged: (by, change) => {
16776
- this.metaChanged(state.id, session, by, change);
17774
+ metaChanged: (by, change, at) => {
17775
+ this.metaChanged(state.id, session, by, change, at);
16777
17776
  }
16778
17777
  });
16779
17778
  this.sessions.set(state.id, session);
@@ -16795,7 +17794,7 @@ var Groups = class {
16795
17794
  top
16796
17795
  });
16797
17796
  const name = (key) => key === session.myKey ? "You" : session.state.nicks[key] ?? `Member ${key.slice(0, 8)}`;
16798
- const when = Date.now();
17797
+ const when = moved ? receivedTimestamp(top.ts) : Date.now();
16799
17798
  if (session.status === "removed") {
16800
17799
  if (statusChanged) await this.event(groupId, "removed", session.state.statusReason ?? "You were removed from this group", when, session.epoch);
16801
17800
  } else if (session.status === "forked") {
@@ -16817,9 +17816,11 @@ var Groups = class {
16817
17816
  this.host.membersChanged?.(groupId);
16818
17817
  this.host.emit();
16819
17818
  }
16820
- async metaChanged(groupId, session, by, change) {
17819
+ /** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
17820
+ async metaChanged(groupId, session, by, change, at) {
16821
17821
  const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
16822
- for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, Date.now(), session.epoch, by);
17822
+ const when = at === void 0 ? Date.now() : receivedTimestamp(at);
17823
+ for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
16823
17824
  this.host.emit();
16824
17825
  }
16825
17826
  /**
@@ -16832,7 +17833,7 @@ var Groups = class {
16832
17833
  const existing = this.host.edges(groupId);
16833
17834
  const left = group?.left;
16834
17835
  const removed = this.removedAt.get(groupId);
16835
- const wanted = session?.status === "active" && group ? this.hubs.edgesWanted(groupId, session, group, this.now()) : left ? /* @__PURE__ */ new Set([left.admin, ...left.hubs ?? []]) : removed !== void 0 && this.now() - removed < REMOVED_LINGER_MS ? new Set(existing.keys()) : /* @__PURE__ */ new Set();
17836
+ const wanted = session?.status === "active" && group ? this.edgesWanted(groupId, session, group, this.now()) : left ? /* @__PURE__ */ new Set([left.admin, ...left.hubs ?? []]) : removed !== void 0 && this.now() - removed < REMOVED_LINGER_MS ? new Set(existing.keys()) : /* @__PURE__ */ new Set();
16836
17837
  for (const [key, linkId] of existing) if (!wanted.has(key)) await this.host.closeEdge(linkId);
16837
17838
  const met = this.justMet.get(groupId);
16838
17839
  if (session) {
@@ -16854,11 +17855,9 @@ var Groups = class {
16854
17855
  this.hubs.met(groupId, key, this.now());
16855
17856
  }
16856
17857
  /** `member`: whom it is about, so the apps can name them as they are known now, not as they were then. */
16857
- /** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `Communities.event`. */
17858
+ /** A line in the group's history; lines at the same time (two members gone in one change) get distinct ones, see `eventTime`. */
16858
17859
  async event(groupId, event, text, timestamp, epoch, member) {
16859
- const last = this.lastEventAt.get(groupId) ?? 0;
16860
- if (timestamp <= last) timestamp = last + 1;
16861
- this.lastEventAt.set(groupId, Math.max(last, timestamp));
17860
+ timestamp = eventTime(this.eventTimes, groupId, timestamp);
16862
17861
  await this.host.storeMessage({
16863
17862
  linkId: MESSAGE_LINK(groupId),
16864
17863
  id: `event:${epoch}:${event}:${timestamp}`,
@@ -16870,7 +17869,7 @@ var Groups = class {
16870
17869
  via: "datalink"
16871
17870
  });
16872
17871
  }
16873
- lastEventAt = /* @__PURE__ */ new Map();
17872
+ eventTimes = /* @__PURE__ */ new Map();
16874
17873
  };
16875
17874
  //#endregion
16876
17875
  //#region ../browser/src/engine/groupEdges.ts
@@ -16884,7 +17883,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
16884
17883
  linkId: live.stored.id,
16885
17884
  state: "waiting",
16886
17885
  noSlot: true,
16887
- lastSeenAt: Math.max(live.presence.lastPacketAt, live.lastSyncAt)
17886
+ lastSeenAt: Math.max(presenceSeenAt(live.presence), live.lastSyncAt)
16888
17887
  };
16889
17888
  const error = open ? void 0 : live.pairing?.status === "error" ? live.pairing.error : live.discoveryError;
16890
17889
  const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
@@ -16892,7 +17891,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
16892
17891
  linkId: live.stored.id,
16893
17892
  state,
16894
17893
  ...open && live.pairing?.transport ? { transport: live.pairing.transport } : {},
16895
- lastSeenAt: open ? now : Math.max(live.presence.lastPacketAt, live.lastSyncAt),
17894
+ lastSeenAt: open ? now : Math.max(presenceSeenAt(live.presence), live.lastSyncAt),
16896
17895
  ...error ? { error } : {}
16897
17896
  };
16898
17897
  }
@@ -17379,7 +18378,7 @@ var GroupEdits = class {
17379
18378
  if (!takes(current) || (current.edit?.seq ?? 0) >= edit.e) return null;
17380
18379
  const next = withEdit(current, {
17381
18380
  seq: edit.e,
17382
- at: receivedTimestamp(edit.ts),
18381
+ at: heardTime(edit.ts),
17383
18382
  text: edit.m,
17384
18383
  card: edit.sc
17385
18384
  });
@@ -17876,6 +18875,14 @@ var POLL_IDLE_MS = 3e5;
17876
18875
  var RENEW_AFTER_MS = 3456e5;
17877
18876
  /** My pointer is published again this often while something is held, in case a relay forgot it. */
17878
18877
  var REPUBLISH_MS = 36e5;
18878
+ /**
18879
+ * A held item's address has this long to answer and be read whole, plus the time its declared size takes at the
18880
+ * slowest pace worth waiting for (64 KiB a second): about 21 s for a manifest, two and a half minutes for the largest
18881
+ * item. Past it the read is given up as any failed one is, and the next poll starts there again.
18882
+ */
18883
+ var HOLD_FETCH_MS = 2e4;
18884
+ var HOLD_FETCH_BYTES_PER_S = 65536;
18885
+ var holdFetchTimeoutMs = (max) => HOLD_FETCH_MS + Math.ceil(max / HOLD_FETCH_BYTES_PER_S) * 1e3;
17879
18886
  var hex$2 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
17880
18887
  /**
17881
18888
  * Store-and-forward for away contacts (WISP 4xx, `hold/1`), on both ends of every chat that turned it
@@ -18054,7 +19061,7 @@ var HoldEngine = class {
18054
19061
  name: heldName(storage.space, mailbox, seq),
18055
19062
  bytes: item.bytes,
18056
19063
  ts: item.timestamp,
18057
- expires: now + this.ttl(),
19064
+ expires: now + Math.min(this.ttl(), HOLD_LIMITS.ttlMs - HOLD_DATED_BACK_MS),
18058
19065
  state: "queued"
18059
19066
  };
18060
19067
  await this.save(linkId, {
@@ -18252,7 +19259,7 @@ var HoldEngine = class {
18252
19259
  const now = this.now();
18253
19260
  const held = hold.outbox.filter((e) => e.state === "held");
18254
19261
  let manifestUrl = null;
18255
- const expires = now + HOLD_LIMITS.ttlMs;
19262
+ const dated = now - HOLD_DATED_BACK_MS, expires = dated + HOLD_LIMITS.ttlMs;
18256
19263
  if (held.length && storage && hold.mailbox) {
18257
19264
  if (rewriteManifest || !hold.manifestSignedAt) {
18258
19265
  const entries = [];
@@ -18268,7 +19275,7 @@ var HoldEngine = class {
18268
19275
  mailbox: hold.mailbox,
18269
19276
  seq: hold.pointerRev + 1,
18270
19277
  id: `manifest-${hold.pointerRev + 1}`,
18271
- ts: now,
19278
+ ts: dated,
18272
19279
  kind: "manifest",
18273
19280
  meta: { entries },
18274
19281
  expires
@@ -18289,7 +19296,7 @@ var HoldEngine = class {
18289
19296
  }
18290
19297
  const pointer = {
18291
19298
  rev: hold.pointerRev + 1,
18292
- issued: now,
19299
+ issued: dated,
18293
19300
  expires,
18294
19301
  manifestUrl,
18295
19302
  top: hold.outSeq,
@@ -18366,7 +19373,7 @@ var HoldEngine = class {
18366
19373
  this.host.changed();
18367
19374
  return;
18368
19375
  }
18369
- const pointer = packet ? keys.readPointer(packet, now) : null;
19376
+ const pointer = packet ? keys.readPointer(packet) : null;
18370
19377
  if (!pointer) return;
18371
19378
  let hold = this.state(linkId);
18372
19379
  if (pointer.rev < hold.peerPointerRev) return;
@@ -18409,7 +19416,7 @@ var HoldEngine = class {
18409
19416
  });
18410
19417
  if (manifest.header.kind !== "manifest") throw new HoldRefusedError("format", "Not a manifest");
18411
19418
  mailbox = manifest.header.mailbox;
18412
- entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq);
19419
+ entries = readManifest(manifest.header.meta).filter(([seq]) => seq > this.state(linkId).inSeq).sort((a, b) => a[0] - b[0]);
18413
19420
  } catch (error) {
18414
19421
  if (error instanceof HoldRefusedError) {
18415
19422
  await this.save(linkId, {
@@ -18527,11 +19534,33 @@ var HoldEngine = class {
18527
19534
  }
18528
19535
  this.host.changed();
18529
19536
  }
18530
- /** A presigned address read whole, but never past `max` bytes: what the manifest declared, not what a server sends. */
19537
+ /**
19538
+ * A presigned address read whole, but never past `max` bytes: what the manifest declared, not what a server sends.
19539
+ * And never past `holdFetchTimeoutMs(max)`: a request that hangs (no answer, or a body that stops) is aborted and
19540
+ * fails as a network problem does. Before, one hung request held the pickup, and every chat's after it, for good.
19541
+ */
18531
19542
  async fetchBytes(url, max) {
19543
+ const abort = new AbortController();
19544
+ let timer;
19545
+ const late = new Promise((_, reject) => {
19546
+ timer = setTimeout(() => {
19547
+ reject(/* @__PURE__ */ new Error("The storage did not answer in time"));
19548
+ abort.abort();
19549
+ }, holdFetchTimeoutMs(max));
19550
+ });
19551
+ const read = this.readBytes(url, max, abort.signal);
19552
+ read.catch(() => {});
19553
+ try {
19554
+ return await Promise.race([read, late]);
19555
+ } finally {
19556
+ clearTimeout(timer);
19557
+ }
19558
+ }
19559
+ async readBytes(url, max, signal) {
18532
19560
  const response = await (this.host.fetch ?? ((...args) => fetch(...args)))(url, {
18533
19561
  cache: "no-store",
18534
- credentials: "omit"
19562
+ credentials: "omit",
19563
+ signal
18535
19564
  });
18536
19565
  if (!response.ok) throw new Error(`The storage answered ${response.status}`);
18537
19566
  if (Number(response.headers.get("content-length")) > max) throw new HoldRefusedError("size", "The held item is larger than the manifest said");
@@ -18541,6 +19570,9 @@ var HoldEngine = class {
18541
19570
  return bytes;
18542
19571
  }
18543
19572
  const reader = response.body.getReader();
19573
+ signal.addEventListener("abort", () => {
19574
+ reader.cancel().catch(() => {});
19575
+ }, { once: true });
18544
19576
  const chunks = [];
18545
19577
  let size = 0;
18546
19578
  for (let next = await reader.read(); !next.done; next = await reader.read()) {
@@ -19376,7 +20408,7 @@ var ProfilePeek = class {
19376
20408
  const keys = new HoldKeys(link, link.participationSeed, link.pairedPeerKey);
19377
20409
  const packet = await transport.resolve(keys.peerAddress, { background: true });
19378
20410
  const now = this.now(), hold = link.hold;
19379
- const pointer = packet ? keys.readPointer(packet, now) : null;
20411
+ const pointer = packet ? keys.readPointer(packet) : null;
19380
20412
  if (pointer && pointer.rev >= hold.peerPointerRev && pointer.expires > now && pointer.top > hold.inSeq) held = Math.min(pointer.count, pointer.top - hold.inSeq);
19381
20413
  }
19382
20414
  return {
@@ -19651,6 +20683,10 @@ var S3Store = class {
19651
20683
  *
19652
20684
  * Secrets are random, so there is no seed to restore from yet. `exportTokens`
19653
20685
  * is the backup, and balances are meant to stay pocket-money sized.
20686
+ *
20687
+ * Every swap is written down before the mint is asked (`PendingSwap`, or the payment intent of a reviewed payment),
20688
+ * with its blinded outputs and their secrets: the mint signs those outputs and no others, and its signatures can be
20689
+ * asked for again (NUT-09) at any time.
19654
20690
  */
19655
20691
  var UNIT$1 = "sat";
19656
20692
  var QUOTE_POLL_MS = 4e3;
@@ -19660,11 +20696,37 @@ var MAX_AMOUNT = 1e6;
19660
20696
  /** How long "Get test coins" waits for the test mint to mark its invoice paid before saying the coins come later. */
19661
20697
  var TEST_COINS_WAIT_MS = 3e4;
19662
20698
  var HISTORY_SHOWN = 100;
20699
+ /** How long before a mint that could not say which restored ecash is spent is asked again. */
20700
+ var RESTORED_POLL_MS = 6e4;
20701
+ /** How long before a mint that could not say where a swap stands is asked again. */
20702
+ var SWAP_POLL_MS = 3e4;
20703
+ /** The same, for a swap that holds nothing any more or whose mint cannot give signatures back: asked now and then. */
20704
+ var SWAP_SLOW_POLL_MS = 6e5;
20705
+ /** How long a swap whose inputs were given back is still asked about, in case the mint made it after all. */
20706
+ var SWAP_WATCH_MS = 36e5;
19663
20707
  function reviewedCashuFee(preview) {
19664
20708
  const fee = preview.inputs.reduce((sum, proof) => sum + Amount.from(proof.amount).toNumber(), 0) - (preview.keepOutputs ?? []).reduce((sum, output) => sum + Amount.from(output.blindedMessage.amount).toNumber(), 0) - preview.amount.toNumber();
19665
20709
  if (!Number.isSafeInteger(fee) || fee < 0) throw new Error("Invalid Cashu prepared fee");
19666
20710
  return fee;
19667
20711
  }
20712
+ /**
20713
+ * The mint was asked for a swap and has not answered. The swap is written down: the wallet asks the mint where it
20714
+ * stands until it knows, then stores what it brought or frees what it held. Nothing is known to have failed.
20715
+ */
20716
+ var SwapUnsettledError = class extends Error {
20717
+ swap;
20718
+ cause;
20719
+ constructor(swap, cause) {
20720
+ const host = new URL(swap.mint).host;
20721
+ super(swap.kind === "receive" ? `${host} has not answered yet. The ecash is added to your wallet as soon as it does.` : `${host} has not answered yet. The payment was not made. The sats set aside for it return to your wallet a few minutes after the mint answers.`);
20722
+ this.swap = swap;
20723
+ this.cause = cause;
20724
+ this.name = "SwapUnsettledError";
20725
+ }
20726
+ };
20727
+ /** When a swap with no answer can no longer reach the mint: a request's longest life, and a margin after its last try. */
20728
+ var swapOverdueAt = (swap) => Math.max(swap.createdAt + CASHU_REQUEST_TIMEOUT_MS + SWAP_SETTLED_MS, (swap.attemptEndedAt ?? Number.POSITIVE_INFINITY) + SWAP_SETTLED_MS);
20729
+ var swapOf = (swap) => swap.swap;
19668
20730
  function normalizeMintUrl(input) {
19669
20731
  let url;
19670
20732
  try {
@@ -19695,6 +20757,7 @@ var toStored = (mint, p, reserved) => ({
19695
20757
  });
19696
20758
  /** The note of the ecash "Get test coins" brought (the engine plays its own sound for it). */
19697
20759
  var TEST_COINS_NOTE = "Test coins from the test mint";
20760
+ var swapId = () => `swap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
19698
20761
  var walletTx = (mint, kind, amount, fee, note) => ({
19699
20762
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
19700
20763
  timestamp: Date.now(),
@@ -19728,8 +20791,13 @@ var CashuWallet = class {
19728
20791
  locks = /* @__PURE__ */ new Map();
19729
20792
  quoteTimer = null;
19730
20793
  meltTimer = null;
20794
+ restoredTimer = null;
20795
+ swapTimer = null;
20796
+ swapTimerAt = 0;
19731
20797
  /** Per melt quote that ended unpaid: the sats the mint kept all the same (the fee of the split before the melt). */
19732
20798
  failedFees = /* @__PURE__ */ new Map();
20799
+ /** Melt quotes that ended unpaid with proofs the mint read spent: ecash spent somewhere else, said as such. */
20800
+ spentAway = /* @__PURE__ */ new Set();
19733
20801
  /**
19734
20802
  * `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
19735
20803
  * invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
@@ -19743,6 +20811,8 @@ var CashuWallet = class {
19743
20811
  start() {
19744
20812
  this.pollQuotes();
19745
20813
  this.pollMelts();
20814
+ this.checkRestored();
20815
+ this.pollSwaps();
19746
20816
  for (const mint of this.getKnownMints()) this.checkMint(mint).then(() => this.events.onChange(), () => {});
19747
20817
  }
19748
20818
  /** The Cashu wallet of one network: its mints and their balance. History is every network's, newest first. */
@@ -19756,13 +20826,57 @@ var CashuWallet = class {
19756
20826
  }));
19757
20827
  const all = await wrap((await store(STORES.walletTx, "readonly")).getAll());
19758
20828
  all.sort((a, b) => b.timestamp - a.timestamp);
20829
+ const here = new Set(mints.map((m) => m.url));
20830
+ const swaps = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
20831
+ const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
20832
+ const spentAway = new Set(swaps.filter((s) => s.stuck && s.kind === "send").flatMap((s) => swapOf(s).inputs.map((p) => p.secret)));
19759
20833
  return {
19760
20834
  mints,
19761
20835
  balance: total(mints.map((m) => ({ amount: m.balance }))),
20836
+ setAside: total(proofs.filter((p) => here.has(p.mint) && p.reserved && !spentAway.has(p.secret))),
20837
+ openSwaps: swaps.length,
20838
+ swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
20839
+ unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
19762
20840
  history: all.slice(0, HISTORY_SHOWN),
19763
20841
  feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
19764
20842
  };
19765
20843
  }
20844
+ /**
20845
+ * Ecash a profile restore brought back (`unchecked`) is a copy from when the backup was made. What was spent since
20846
+ * (by the profile it was copied from, or by this one before it was restored) still counted in the balance, for good,
20847
+ * and every payment that picked one of those proofs failed with the mint's "Token already spent". Each mint is asked
20848
+ * once which of them it still reads unspent: a spent proof is dropped, an unspent one is checked for good, and one
20849
+ * the mint holds pending, or a mint that cannot be asked, is asked again later.
20850
+ */
20851
+ async checkRestored() {
20852
+ if (this.restoredTimer) clearTimeout(this.restoredTimer);
20853
+ this.restoredTimer = null;
20854
+ const waiting = (p) => !!p.unchecked && !p.reserved;
20855
+ let again = false, changed = false;
20856
+ for (const mint of new Set((await this.allProofs()).filter(waiting).map((p) => p.mint))) try {
20857
+ await this.locked(mint, async () => {
20858
+ const proofs = (await this.allProofs()).filter((p) => p.mint === mint && waiting(p));
20859
+ if (!proofs.length) return;
20860
+ const states = await (await this.wallet(mint)).checkProofsStates(proofs);
20861
+ if (states.length !== proofs.length) throw new Error("The mint answered for another number of proofs");
20862
+ await transact([STORES.proofs], (stores) => {
20863
+ proofs.forEach((proof, i) => {
20864
+ const state = states[i]?.state;
20865
+ if (state === "SPENT") stores[STORES.proofs].delete(proof.secret);
20866
+ else if (state === "UNSPENT") {
20867
+ const { unchecked: _checked, ...kept } = proof;
20868
+ stores[STORES.proofs].put(kept);
20869
+ } else again = true;
20870
+ });
20871
+ });
20872
+ changed = true;
20873
+ });
20874
+ } catch {
20875
+ again = true;
20876
+ }
20877
+ if (changed) this.events.onChange();
20878
+ if (again) this.restoredTimer = setTimeout(() => void this.checkRestored(), RESTORED_POLL_MS);
20879
+ }
19766
20880
  /** Talks to the mint before it is added: a typo should not become a place to keep money. */
19767
20881
  async checkMint(url) {
19768
20882
  const normalized = normalizeMintUrl(url);
@@ -19923,12 +21037,16 @@ var CashuWallet = class {
19923
21037
  const quote = await wrap((await store(STORES.quotes, "readonly")).get(id));
19924
21038
  if (!quote || quote.issuedUnclaimed || quote.held) return null;
19925
21039
  const wallet = await this.wallet(quote.mint);
19926
- const { state } = await wallet.checkMintQuoteBolt11(quote.quote);
21040
+ const answer = await wallet.checkMintQuoteBolt11(quote.quote);
21041
+ const { state } = answer;
19927
21042
  if (state === "UNPAID") {
19928
21043
  if (quote.expiresAt && quote.expiresAt + 6e4 < Date.now()) await wrap((await store(STORES.quotes, "readwrite")).delete(quote.quote));
19929
21044
  return null;
19930
21045
  }
21046
+ const saved = quote.outputs?.map((o) => OutputData.deserialize(o));
19931
21047
  if (state === "ISSUED") {
21048
+ const issued = saved?.length ? await this.signedOutputs(wallet, saved) : void 0;
21049
+ if (Array.isArray(issued)) return this.storeMinted(quote, issued);
19932
21050
  console.warn(`[wallet] ${quote.mint} issued quote ${quote.quote} (${quote.amount} sats) but this wallet holds no ecash for it`);
19933
21051
  await wrap((await store(STORES.quotes, "readwrite")).put({
19934
21052
  ...quote,
@@ -19936,11 +21054,31 @@ var CashuWallet = class {
19936
21054
  }));
19937
21055
  return null;
19938
21056
  }
19939
- if (!quote.paid) await wrap((await store(STORES.quotes, "readwrite")).put({
21057
+ const preview = await wallet.prepareMint("bolt11", quote.amount, {
21058
+ ...answer,
21059
+ quote: quote.quote
21060
+ });
21061
+ if (saved) preview.outputData = saved;
21062
+ const written = {
19940
21063
  ...quote,
19941
- paid: true
19942
- }));
19943
- const proofs = await wallet.mintProofsBolt11(quote.amount, quote.quote);
21064
+ paid: true,
21065
+ outputs: saved ? quote.outputs : preview.outputData.map((o) => OutputData.serialize(o))
21066
+ };
21067
+ if (!quote.paid || !saved) await wrap((await store(STORES.quotes, "readwrite")).put(written));
21068
+ let proofs;
21069
+ try {
21070
+ proofs = await wallet.completeMint(preview);
21071
+ } catch (error) {
21072
+ if (saved && isMintOperationError(error) && (await wallet.checkMintQuoteBolt11(quote.quote).catch(() => null))?.state === "PAID") {
21073
+ const { outputs: _refused, ...fresh } = written;
21074
+ await wrap((await store(STORES.quotes, "readwrite")).put(fresh));
21075
+ }
21076
+ throw error;
21077
+ }
21078
+ return this.storeMinted(quote, proofs);
21079
+ }
21080
+ /** The ecash of a paid invoice goes in with its history line, in the transaction that drops the quote. */
21081
+ async storeMinted(quote, proofs) {
19944
21082
  const minted = sats(proofs);
19945
21083
  const tx = walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0);
19946
21084
  await transact([
@@ -19977,24 +21115,24 @@ var CashuWallet = class {
19977
21115
  for (const mint of candidates) {
19978
21116
  if (await this.balanceAt(mint) < amount) continue;
19979
21117
  return this.locked(mint, async () => {
19980
- const wallet = await this.wallet(mint);
19981
- const proofs = await this.proofsAt(mint);
19982
- const { keep, send } = await wallet.send(amount, asProofLike(proofs), { includeFees: true });
21118
+ const { inputs, keep, send, swap } = await this.split(mint, amount, await this.proofsAt(mint));
19983
21119
  const token = getEncodedToken({
19984
21120
  mint,
19985
21121
  proofs: send,
19986
21122
  unit: UNIT$1
19987
21123
  });
19988
- const spent = total(proofs) - sats(keep);
21124
+ const spent = total(inputs) - sats(keep);
19989
21125
  const tx = walletTx(mint, "ecash-out", amount, spent - amount, note);
19990
21126
  const kept = outbox?.(token, mint);
19991
21127
  await transact([
19992
21128
  STORES.proofs,
19993
21129
  STORES.walletTx,
21130
+ STORES.swaps,
19994
21131
  ...kept ? [STORES.payments] : []
19995
21132
  ], (stores) => {
19996
- this.queueReplace(stores[STORES.proofs], mint, proofs, keep);
21133
+ this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
19997
21134
  stores[STORES.walletTx].put(tx);
21135
+ if (swap) stores[STORES.swaps].delete(swap.id);
19998
21136
  if (kept) stores[STORES.payments].put(kept);
19999
21137
  });
20000
21138
  this.events.onChange();
@@ -20057,8 +21195,31 @@ var CashuWallet = class {
20057
21195
  const keep = (prepared.swap.keepOutputs ?? []).map(OutputData.deserialize);
20058
21196
  const send = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
20059
21197
  const outputs = [...keep, ...send];
21198
+ const restored = await this.restoreOutputs(wallet, outputs);
21199
+ if (!Array.isArray(restored)) return void 0;
21200
+ return this.finishReviewedCashu(review, prepared, restored.slice(0, keep.length), restored.slice(keep.length));
21201
+ }
21202
+ /**
21203
+ * What the mint says of outputs that were written down before it was asked to sign them: the proofs they stand for
21204
+ * when it signed them all, `none` when it answered that it signed none, and undefined when it has no way of saying
21205
+ * (it does not restore, NUT-09, by its own info). Only an answer counts: a mint that cannot be reached, answers with
21206
+ * an error or answers for some of the outputs throws, and is asked again later.
21207
+ */
21208
+ async signedOutputs(wallet, outputs) {
21209
+ if (!wallet.getMintInfo().isSupported(9).supported) return void 0;
21210
+ const restored = await this.restoreOutputs(wallet, outputs);
21211
+ if (restored === "some") throw new Error("The mint answered for some of the outputs");
21212
+ return restored;
21213
+ }
21214
+ /**
21215
+ * The mint's signatures for these exact outputs (NUT-09), as the proofs they stand for: all of them, `none`, or
21216
+ * `some` (an answer that does not cover every output). It only reads what the mint already signed: nothing is
21217
+ * swapped again.
21218
+ */
21219
+ async restoreOutputs(wallet, outputs) {
20060
21220
  const recovered = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
20061
- if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return void 0;
21221
+ if (recovered.outputs.length === 0 && recovered.signatures.length === 0) return "none";
21222
+ if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return "some";
20062
21223
  const keys = (await wallet.mint.getKeys()).keysets;
20063
21224
  const restored = [];
20064
21225
  for (const output of outputs) {
@@ -20068,7 +21229,7 @@ var CashuWallet = class {
20068
21229
  if (!signature || !key || signature.id !== output.blindedMessage.id || Amount.from(signature.amount).toString() !== Amount.from(output.blindedMessage.amount).toString()) throw new Error("Mint restore does not match reviewed outputs");
20069
21230
  restored.push(output.toProof(signature, key));
20070
21231
  }
20071
- return this.finishReviewedCashu(review, prepared, restored.slice(0, keep.length), restored.slice(keep.length));
21232
+ return restored;
20072
21233
  }
20073
21234
  async finishReviewedCashu(review, prepared, keep, send) {
20074
21235
  const token = getEncodedToken({
@@ -20125,6 +21286,72 @@ var CashuWallet = class {
20125
21286
  const restored = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
20126
21287
  return restored.signatures.length === 0 && restored.outputs.length === 0;
20127
21288
  }
21289
+ /**
21290
+ * Whether a reviewed swap can never happen because its ecash was spent by something else: the mint answers that it
21291
+ * signed none of its outputs (a successful NUT-09 answer, asked before and after), and reads an input spent, none
21292
+ * pending. Read-only. A mint that does not restore cannot say, and the answer is no.
21293
+ */
21294
+ async reviewedCashuSpentElsewhere(prepared) {
21295
+ const preview = deserializeSwapPreview(prepared.swap);
21296
+ if (!preview.inputs.length) return false;
21297
+ const wallet = await this.wallet(prepared.mint);
21298
+ const outputs = [...prepared.swap.keepOutputs ?? [], ...prepared.swap.sendOutputs ?? []].map(OutputData.deserialize);
21299
+ if (await this.signedOutputs(wallet, outputs) !== "none") return false;
21300
+ const states = await wallet.checkProofsStates(preview.inputs.map((p) => ({
21301
+ secret: p.secret,
21302
+ id: p.id
21303
+ })));
21304
+ if (states.length !== preview.inputs.length || states.some((s) => s.state === "PENDING") || !states.some((s) => s.state === "SPENT")) return false;
21305
+ return await this.signedOutputs(wallet, outputs) === "none";
21306
+ }
21307
+ /**
21308
+ * Ends a reviewed swap whose ecash was spent elsewhere: the inputs the mint reads spent leave the wallet, the others
21309
+ * are free again, and the rest of the wallet's ecash at that mint is checked the same way. Idempotent.
21310
+ */
21311
+ async dropReviewedCashu(prepared) {
21312
+ const preview = deserializeSwapPreview(prepared.swap);
21313
+ await this.locked(prepared.mint, async () => {
21314
+ const states = await (await this.wallet(prepared.mint)).checkProofsStates(preview.inputs.map((p) => ({
21315
+ secret: p.secret,
21316
+ id: p.id
21317
+ })));
21318
+ if (states.length !== preview.inputs.length) throw new Error("The mint answered for another number of proofs");
21319
+ const spent = new Set(preview.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
21320
+ await transact([STORES.proofs], (stores) => {
21321
+ const proofs = stores[STORES.proofs];
21322
+ for (const input of preview.inputs) {
21323
+ const request = proofs.get(input.secret);
21324
+ request.onsuccess = () => {
21325
+ const proof = request.result;
21326
+ if (!proof || !proof.reserved || proof.mint !== prepared.mint || proof.C !== input.C) return;
21327
+ if (spent.has(input.secret)) {
21328
+ proofs.delete(input.secret);
21329
+ return;
21330
+ }
21331
+ const { reserved: _released, ...free } = proof;
21332
+ proofs.put(free);
21333
+ };
21334
+ }
21335
+ });
21336
+ });
21337
+ await this.recheck(prepared.mint);
21338
+ this.events.onChange();
21339
+ }
21340
+ /**
21341
+ * A proof of this wallet read spent when nothing here spent it: its ecash is in use somewhere else too. Every other
21342
+ * free proof at that mint is asked about once (`checkRestored`), so the balance becomes what the mint still holds
21343
+ * instead of one payment after another finding out.
21344
+ */
21345
+ async recheck(mint) {
21346
+ const free = (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved && !p.unchecked);
21347
+ if (free.length > 0) await transact([STORES.proofs], (stores) => {
21348
+ for (const p of free) stores[STORES.proofs].put({
21349
+ ...p,
21350
+ unchecked: true
21351
+ });
21352
+ });
21353
+ await this.checkRestored();
21354
+ }
20128
21355
  /** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
20129
21356
  async releaseReviewedCashu(prepared) {
20130
21357
  const preview = deserializeSwapPreview(prepared.swap);
@@ -20144,6 +21371,13 @@ var CashuWallet = class {
20144
21371
  });
20145
21372
  this.events.onChange();
20146
21373
  }
21374
+ /**
21375
+ * Whether the ecash of a reviewed payment came back to this wallet (Take it back, or a refusal). Its proofs then read
21376
+ * SPENT at the mint because this wallet redeemed them, not the contact.
21377
+ */
21378
+ async reviewedCashuTakenBack(id) {
21379
+ return (await wrap((await store(STORES.payments, "readonly")).get(id)))?.state === "reclaimed";
21380
+ }
20147
21381
  async reviewedCashuSpent(prepared) {
20148
21382
  const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
20149
21383
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
@@ -20199,18 +21433,40 @@ var CashuWallet = class {
20199
21433
  if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
20200
21434
  if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
20201
21435
  return this.locked(mint, async () => {
20202
- const proofs = await (await this.wallet(mint)).receive(token);
20203
- const credited = sats(proofs);
20204
- const tx = walletTx(mint, kind, credited, Math.max(0, faceValue - credited), note);
20205
- await transact([
20206
- STORES.proofs,
20207
- STORES.walletTx,
20208
- ...payment ? [STORES.payments] : []
20209
- ], (stores) => {
20210
- for (const p of proofs) stores[STORES.proofs].put(toStored(mint, p));
20211
- stores[STORES.walletTx].put(tx);
20212
- if (payment) stores[STORES.payments].put(payment(credited, mint));
21436
+ const records = (credited) => ({
21437
+ tx: walletTx(mint, kind, credited, Math.max(0, faceValue - credited), note),
21438
+ ...payment ? { payment: payment(credited, mint) } : {}
20213
21439
  });
21440
+ let swap = (await this.swapsAt(mint)).find((s) => s.kind === "receive" && s.token === token);
21441
+ if (swap?.finished) return {
21442
+ amount: swap.tx?.amount ?? 0,
21443
+ mint
21444
+ };
21445
+ if (!swap) {
21446
+ const preview = await (await this.wallet(mint)).prepareSwapToReceive(token);
21447
+ swap = {
21448
+ id: swapId(),
21449
+ mint,
21450
+ kind: "receive",
21451
+ swap: serializeSwapPreview(preview),
21452
+ createdAt: Date.now(),
21453
+ token,
21454
+ ...records(preview.amount.toNumber())
21455
+ };
21456
+ await wrap((await store(STORES.swaps, "readwrite")).put(swap));
21457
+ }
21458
+ const { keep } = await this.completeSwap(swap);
21459
+ const credited = sats(keep);
21460
+ if (swap.tx && credited !== swap.tx.amount) swap = {
21461
+ ...swap,
21462
+ ...records(credited),
21463
+ tx: {
21464
+ ...records(credited).tx,
21465
+ id: swap.tx.id,
21466
+ timestamp: swap.tx.timestamp
21467
+ }
21468
+ };
21469
+ await this.finishSwap(swap, keep, [], false);
20214
21470
  this.events.onChange();
20215
21471
  return {
20216
21472
  amount: credited,
@@ -20218,6 +21474,334 @@ var CashuWallet = class {
20218
21474
  };
20219
21475
  });
20220
21476
  }
21477
+ /**
21478
+ * Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
21479
+ * no mint. Otherwise it is a swap: written down, with its inputs reserved, before the mint is asked. The caller
21480
+ * stores the result in one transaction: `inputs` out, `keep` in, `send` where it goes, and `swap` deleted.
21481
+ * Runs under the mint's lock.
21482
+ */
21483
+ async split(mint, amount, proofs) {
21484
+ const wallet = await this.wallet(mint);
21485
+ const among = (chosen) => {
21486
+ const secrets = new Set(chosen.map((p) => p.secret));
21487
+ return proofs.filter((p) => secrets.has(p.secret));
21488
+ };
21489
+ try {
21490
+ const { send } = wallet.sendOffline(amount, asProofLike(proofs), {
21491
+ includeFees: true,
21492
+ exactMatch: true
21493
+ });
21494
+ if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber()) return {
21495
+ inputs: among(send),
21496
+ keep: [],
21497
+ send
21498
+ };
21499
+ } catch {}
21500
+ const preview = await wallet.prepareSwapToSend(amount, asProofLike(proofs), { includeFees: true });
21501
+ const swap = {
21502
+ id: swapId(),
21503
+ mint,
21504
+ kind: "send",
21505
+ swap: serializeSwapPreview(preview),
21506
+ createdAt: Date.now()
21507
+ };
21508
+ const inputs = among(preview.inputs);
21509
+ await transact([STORES.proofs, STORES.swaps], (stores) => {
21510
+ for (const p of inputs) stores[STORES.proofs].put({
21511
+ ...p,
21512
+ reserved: true
21513
+ });
21514
+ stores[STORES.swaps].put(swap);
21515
+ });
21516
+ const { keep, send } = await this.completeSwap(swap);
21517
+ return {
21518
+ inputs,
21519
+ keep,
21520
+ send,
21521
+ swap
21522
+ };
21523
+ }
21524
+ /**
21525
+ * Sends a written-down swap to the mint: always the saved one, so a second try asks for the same outputs. When the
21526
+ * answer is missed the mint is asked what it did, and its word decides: the outputs it signed are the result, as the
21527
+ * answer would have been; a swap it says it never made is undone and the failure is thrown; and for as long as the
21528
+ * swap stays written down nothing is known to have failed, whatever the mint's words were (`SwapUnsettledError`):
21529
+ * `pollSwaps` settles it. Runs under the mint's lock.
21530
+ */
21531
+ async completeSwap(swap) {
21532
+ try {
21533
+ return await (await this.wallet(swap.mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
21534
+ } catch (error) {
21535
+ const refused = isMintOperationError(error) && swap.attemptEndedAt === void 0;
21536
+ const ended = {
21537
+ ...swap,
21538
+ attemptEndedAt: Date.now()
21539
+ };
21540
+ await (async () => wrap((await store(STORES.swaps, "readwrite")).put(ended)))().catch(() => {});
21541
+ const outcome = await this.swapOutcome(ended, refused).catch(() => ({ state: "open" }));
21542
+ if (outcome.state === "done") return outcome;
21543
+ if (outcome.state === "never" && outcome.final) {
21544
+ await this.dropSwap(ended, outcome.gone);
21545
+ throw outcome.gone.size > 0 ? Object.assign(engineError("ecashAlreadySpent"), { cause: error }) : error;
21546
+ }
21547
+ this.swapsAgainIn(SWAP_POLL_MS);
21548
+ this.events.onChange();
21549
+ throw new SwapUnsettledError(ended, error);
21550
+ }
21551
+ }
21552
+ /**
21553
+ * Asks the mint where a written-down swap stands. It only asks: nothing is sent again, nothing is stored.
21554
+ * `refused`: the mint refused the only request ever made for this swap, so no request of it is still on its way.
21555
+ * Throws when the mint cannot be asked, or answers with an error: only its answers count.
21556
+ */
21557
+ async swapOutcome(swap, refused = false) {
21558
+ const wallet = await this.wallet(swap.mint);
21559
+ const saved = swapOf(swap);
21560
+ const keep = (saved.keepOutputs ?? []).map(OutputData.deserialize);
21561
+ const send = (saved.sendOutputs ?? []).map(OutputData.deserialize);
21562
+ const signed = async () => {
21563
+ const proofs = await this.signedOutputs(wallet, [...keep, ...send]);
21564
+ return Array.isArray(proofs) ? {
21565
+ state: "done",
21566
+ keep: proofs.slice(0, keep.length),
21567
+ send: proofs.slice(keep.length)
21568
+ } : proofs;
21569
+ };
21570
+ const first = await signed();
21571
+ if (first && first !== "none") return first;
21572
+ const states = await wallet.checkProofsStates(saved.inputs.map((p) => ({
21573
+ secret: p.secret,
21574
+ id: p.id
21575
+ })));
21576
+ if (states.length !== saved.inputs.length) throw new Error("The mint answered for another number of proofs");
21577
+ if (states.some((s) => s.state === "PENDING")) return { state: "open" };
21578
+ const gone = new Set(saved.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
21579
+ if (gone.size > 0) {
21580
+ const second = await signed();
21581
+ if (second && second !== "none") return second;
21582
+ if (second === "none" || refused) return {
21583
+ state: "never",
21584
+ gone,
21585
+ final: true
21586
+ };
21587
+ return {
21588
+ state: "open",
21589
+ slow: true
21590
+ };
21591
+ }
21592
+ if (refused) return {
21593
+ state: "never",
21594
+ gone,
21595
+ final: true
21596
+ };
21597
+ if (Date.now() >= swapOverdueAt(swap)) return {
21598
+ state: "never",
21599
+ gone,
21600
+ final: false
21601
+ };
21602
+ return {
21603
+ state: "open",
21604
+ idle: !!first
21605
+ };
21606
+ }
21607
+ /**
21608
+ * Stores what a swap brought, in one transaction that reads the swap first: a swap still written down was never
21609
+ * stored, and one stored is never stored again, whoever else works on this database. A redeem brings its ecash with
21610
+ * its history line and its payment record. A split finished here was not finished by its caller (the payment it was
21611
+ * for is over): everything it made is the wallet's. `late`: after the call that made it was over; a swap with a
21612
+ * payment then stays, marked `finished`, until the chat has been told (`tellSwap`). Returns whether it stored.
21613
+ */
21614
+ async finishSwap(swap, keep, send, late) {
21615
+ const secrets = new Set(swap.kind === "send" ? swapOf(swap).inputs.map((p) => p.secret) : []);
21616
+ const inputs = (await this.allProofs()).filter((p) => p.mint === swap.mint && secrets.has(p.secret));
21617
+ const lost = swap.kind === "send" ? swapOf(swap).inputs.reduce((sum, p) => sum + Number(p.amount), 0) - sats(keep) - sats(send) : 0;
21618
+ let stored = false;
21619
+ await transact([
21620
+ STORES.proofs,
21621
+ STORES.swaps,
21622
+ STORES.walletTx,
21623
+ ...swap.payment ? [STORES.payments] : []
21624
+ ], (stores) => {
21625
+ const current = stores[STORES.swaps].get(swap.id);
21626
+ current.onsuccess = () => {
21627
+ const kept = current.result;
21628
+ if (!kept || kept.finished) return;
21629
+ stored = true;
21630
+ for (const p of inputs) stores[STORES.proofs].delete(p.secret);
21631
+ for (const p of [...keep, ...send]) stores[STORES.proofs].put({
21632
+ ...toStored(swap.mint, p),
21633
+ ...swap.restored ? { unchecked: true } : {}
21634
+ });
21635
+ if (swap.tx) stores[STORES.walletTx].put(swap.tx);
21636
+ if (lost > 0) stores[STORES.walletTx].put({
21637
+ ...walletTx(swap.mint, "fee", 0, lost, "A payment was not sent"),
21638
+ id: swap.id
21639
+ });
21640
+ if (swap.payment) stores[STORES.payments].put(swap.payment);
21641
+ if (late && swap.payment) stores[STORES.swaps].put({
21642
+ ...kept,
21643
+ finished: true
21644
+ });
21645
+ else stores[STORES.swaps].delete(swap.id);
21646
+ };
21647
+ });
21648
+ if (stored && swap.restored) this.checkRestored();
21649
+ return stored;
21650
+ }
21651
+ /**
21652
+ * The mint never made this swap. The inputs it held are free again, less any the mint reads spent. `watch`: it is
21653
+ * only overdue, so it stays written down, marked `released`, and is asked about for a while longer: should the mint
21654
+ * have made it after all, what it made is still recovered.
21655
+ */
21656
+ async dropSwap(swap, gone, watch = false) {
21657
+ const inputs = swap.kind === "send" && !swap.released ? await this.storedInputs(swap) : [];
21658
+ if (gone.size > 0 && inputs.length > 0) console.warn(`[wallet] ${gone.size} proofs of a swap at ${swap.mint} that never happened are spent`);
21659
+ await transact([STORES.proofs, STORES.swaps], (stores) => {
21660
+ for (const p of inputs) if (gone.has(p.secret)) stores[STORES.proofs].delete(p.secret);
21661
+ else {
21662
+ const { reserved: _released, ...free } = p;
21663
+ stores[STORES.proofs].put(free);
21664
+ }
21665
+ if (watch) stores[STORES.swaps].put({
21666
+ ...swap,
21667
+ released: true
21668
+ });
21669
+ else stores[STORES.swaps].delete(swap.id);
21670
+ });
21671
+ if (gone.size > 0 && inputs.length > 0) this.recheck(swap.mint).catch(() => {});
21672
+ }
21673
+ /** The proofs a split reserved, as they are stored now. */
21674
+ async storedInputs(swap) {
21675
+ const secrets = new Set(swapOf(swap).inputs.map((p) => p.secret));
21676
+ return (await this.allProofs()).filter((p) => p.mint === swap.mint && p.reserved && secrets.has(p.secret));
21677
+ }
21678
+ async swapsAt(mint) {
21679
+ return (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => s.mint === mint);
21680
+ }
21681
+ /** Says what became of a swap to whoever follows it (the chat its payment is in), and waits for it to be taken in. */
21682
+ async tellSwap(swap, done) {
21683
+ try {
21684
+ await this.events.onSwapSettled?.(swap, done);
21685
+ return true;
21686
+ } catch {
21687
+ return false;
21688
+ }
21689
+ }
21690
+ swapsAgainIn(ms) {
21691
+ if (this.swapTimer && this.swapTimerAt <= Date.now() + ms) return;
21692
+ if (this.swapTimer) clearTimeout(this.swapTimer);
21693
+ this.swapTimerAt = Date.now() + ms;
21694
+ this.swapTimer = setTimeout(() => void this.pollSwaps(), ms);
21695
+ }
21696
+ /**
21697
+ * Swaps still written down: the app closed while the mint was being asked, or the mint did not answer. Each mint is
21698
+ * asked where its own stand, under its lock. A swap it made is stored as its answer would have been; one it says it
21699
+ * never made is undone; one that is only overdue gives its inputs back and stays watched; one it cannot speak for
21700
+ * is asked about again. `mints`: only these mints', once, now.
21701
+ */
21702
+ /** Asks these mints about their open swaps now (before a wallet's removal is shown or refused). */
21703
+ settleSwaps(mints) {
21704
+ return this.pollSwaps(mints);
21705
+ }
21706
+ async pollSwaps(mints) {
21707
+ if (!mints) {
21708
+ if (this.swapTimer) clearTimeout(this.swapTimer);
21709
+ this.swapTimer = null;
21710
+ }
21711
+ const open = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => !mints || mints.includes(s.mint));
21712
+ let again = 0;
21713
+ const later = (ms) => {
21714
+ again = again ? Math.min(again, ms) : ms;
21715
+ };
21716
+ for (const mint of new Set(open.map((s) => s.mint))) await this.locked(mint, async () => {
21717
+ for (let swap of await this.swapsAt(mint)) try {
21718
+ if (swap.finished) {
21719
+ if (await this.tellSwap(swap, true)) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
21720
+ else later(SWAP_POLL_MS);
21721
+ continue;
21722
+ }
21723
+ if (swap.askAt && Date.now() < swap.askAt && !mints) {
21724
+ later(swap.askAt - Date.now());
21725
+ continue;
21726
+ }
21727
+ if (swap.attemptEndedAt === void 0) {
21728
+ swap = {
21729
+ ...swap,
21730
+ attemptEndedAt: Date.now()
21731
+ };
21732
+ await wrap((await store(STORES.swaps, "readwrite")).put(swap));
21733
+ }
21734
+ const outcome = await this.swapOutcome(swap);
21735
+ if (outcome.state === "open") {
21736
+ if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) {
21737
+ await this.dropSwap(swap, /* @__PURE__ */ new Set());
21738
+ continue;
21739
+ }
21740
+ if (outcome.slow) {
21741
+ const stuck = (swap.stuck ?? 0) + 1;
21742
+ const wait = stuck <= 6 ? SWAP_SLOW_POLL_MS : stuck <= 30 ? 36e5 : 864e5;
21743
+ await wrap((await store(STORES.swaps, "readwrite")).put({
21744
+ ...swap,
21745
+ stuck,
21746
+ askAt: Date.now() + wait
21747
+ }));
21748
+ if (stuck === 1) this.events.onChange();
21749
+ later(wait);
21750
+ } else if (outcome.idle && swap.kind === "receive" && !swap.released) try {
21751
+ const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
21752
+ if (await this.finishSwap(swap, keep, [], true)) {
21753
+ const told = await this.tellSwap(swap, true);
21754
+ if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
21755
+ else if (swap.payment) later(SWAP_POLL_MS);
21756
+ this.events.onChange();
21757
+ }
21758
+ } catch (error) {
21759
+ swap = {
21760
+ ...swap,
21761
+ attemptEndedAt: Date.now()
21762
+ };
21763
+ await wrap((await store(STORES.swaps, "readwrite")).put(swap));
21764
+ const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
21765
+ if (refusal?.state === "done") {
21766
+ if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
21767
+ const told = await this.tellSwap(swap, true);
21768
+ if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
21769
+ }
21770
+ this.events.onChange();
21771
+ } else if (refusal?.state === "never") {
21772
+ await this.dropSwap(swap, refusal.gone);
21773
+ await this.tellSwap(swap, false);
21774
+ this.events.onChange();
21775
+ } else later(SWAP_POLL_MS);
21776
+ }
21777
+ else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
21778
+ continue;
21779
+ }
21780
+ if (outcome.state === "done") {
21781
+ if (await this.finishSwap(swap, outcome.keep, outcome.send, true)) {
21782
+ const told = await this.tellSwap(swap, true);
21783
+ if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
21784
+ else if (swap.payment) later(SWAP_POLL_MS);
21785
+ }
21786
+ } else if (swap.released) {
21787
+ if (outcome.final || Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, outcome.gone);
21788
+ else later(SWAP_SLOW_POLL_MS);
21789
+ continue;
21790
+ } else {
21791
+ await this.dropSwap(swap, outcome.gone, !outcome.final);
21792
+ await this.tellSwap(swap, false);
21793
+ if (!outcome.final) later(SWAP_SLOW_POLL_MS);
21794
+ }
21795
+ this.events.onChange();
21796
+ } catch {
21797
+ if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, /* @__PURE__ */ new Set()).catch(() => {});
21798
+ else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
21799
+ }
21800
+ }).catch(() => {
21801
+ later(SWAP_POLL_MS);
21802
+ });
21803
+ if (again && !mints) this.swapsAgainIn(again);
21804
+ }
20221
21805
  /** What paying this invoice would cost, from the first mint that can afford it. */
20222
21806
  async quoteInvoice(invoice, network) {
20223
21807
  let lastError = /* @__PURE__ */ new Error("Add a mint in Settings first");
@@ -20248,7 +21832,8 @@ var CashuWallet = class {
20248
21832
  /**
20249
21833
  * Pays a melt quote. True once paid; false while the mint holds the payment pending, in which case its
20250
21834
  * proofs stay reserved and the wallet keeps asking until the mint settles it (`onMeltResolved`).
20251
- * Throws only when the sats never left the wallet or are known to be back in it.
21835
+ * Throws only when the sats never left the wallet or are known to be back in it, or when the split before the payment
21836
+ * has no answer yet (`SwapUnsettledError`): the payment was not made, and the split is settled by `pollSwaps`.
20252
21837
  */
20253
21838
  async payQuote(quoteId, mint, note, paymentId) {
20254
21839
  try {
@@ -20265,13 +21850,15 @@ var CashuWallet = class {
20265
21850
  const quote = await wallet.checkMeltQuoteBolt11(quoteId);
20266
21851
  if ((await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
20267
21852
  const needed = quote.amount.toNumber() + quote.fee_reserve.toNumber();
20268
- const proofs = await this.proofsAt(mint);
20269
- const { keep, send } = await wallet.send(needed, asProofLike(proofs), { includeFees: true });
21853
+ const { inputs, keep, send, swap } = await this.split(mint, needed, await this.proofsAt(mint));
20270
21854
  let preview;
20271
21855
  try {
20272
21856
  preview = await wallet.prepareMelt("bolt11", quote, send);
20273
21857
  } catch (error) {
20274
- await this.replaceProofs(mint, proofs, [...keep, ...send]);
21858
+ await transact([STORES.proofs, STORES.swaps], (stores) => {
21859
+ this.queueReplace(stores[STORES.proofs], mint, inputs, [...keep, ...send]);
21860
+ if (swap) stores[STORES.swaps].delete(swap.id);
21861
+ });
20275
21862
  throw error;
20276
21863
  }
20277
21864
  const melt = {
@@ -20280,16 +21867,21 @@ var CashuWallet = class {
20280
21867
  request: quote.request,
20281
21868
  amount: quote.amount.toNumber(),
20282
21869
  secrets: send.map((p) => p.secret),
20283
- outlay: total(proofs) - sats(keep),
21870
+ outlay: total(inputs) - sats(keep),
20284
21871
  outputs: preview.outputData.map((o) => OutputData.serialize(o)),
20285
21872
  note: note?.slice(0, 140),
20286
21873
  paymentId,
20287
21874
  createdAt: Date.now()
20288
21875
  };
20289
- await transact([STORES.proofs, STORES.melts], (stores) => {
20290
- this.queueReplace(stores[STORES.proofs], mint, proofs, keep);
21876
+ await transact([
21877
+ STORES.proofs,
21878
+ STORES.melts,
21879
+ STORES.swaps
21880
+ ], (stores) => {
21881
+ this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
20291
21882
  for (const p of send) stores[STORES.proofs].put(toStored(mint, p, true));
20292
21883
  stores[STORES.melts].put(melt);
21884
+ if (swap) stores[STORES.swaps].delete(swap.id);
20293
21885
  });
20294
21886
  let result;
20295
21887
  try {
@@ -20299,6 +21891,7 @@ var CashuWallet = class {
20299
21891
  const outcome = await this.settleMelt(melt).catch(() => "pending");
20300
21892
  if (outcome === "unpaid") {
20301
21893
  const lost = this.takeFailedFee(melt.quote);
21894
+ if (this.spentAway.delete(melt.quote)) throw Object.assign(engineError("ecashAlreadySpent"), { cause: error });
20302
21895
  if (lost > 0) throw Object.assign(/* @__PURE__ */ new Error(`${error instanceof Error ? error.message : String(error)} ${backInWallet(lost)}`), { cause: error });
20303
21896
  throw error;
20304
21897
  }
@@ -20310,6 +21903,13 @@ var CashuWallet = class {
20310
21903
  }
20311
21904
  return this.settleMelt(melt).catch(() => "pending");
20312
21905
  }
21906
+ /**
21907
+ * Whether a Lightning payment of this invoice is written down as in flight. A melt is written before the mint sees
21908
+ * its proofs, and `pollMelts` settles and reports it: an invoice with none never reached the mint's melt, or is done.
21909
+ */
21910
+ async meltInFlight(invoice) {
21911
+ return (await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => m.request === invoice);
21912
+ }
20313
21913
  /** Lightning payments left pending, asked about again until the mint settles each one. */
20314
21914
  async pollMelts() {
20315
21915
  if (this.meltTimer) clearTimeout(this.meltTimer);
@@ -20322,6 +21922,7 @@ var CashuWallet = class {
20322
21922
  });
20323
21923
  if (outcome === "paid" || outcome === "unpaid") {
20324
21924
  this.takeFailedFee(melt.quote);
21925
+ this.spentAway.delete(melt.quote);
20325
21926
  this.events.onMeltResolved(melt, outcome === "paid");
20326
21927
  this.events.onChange();
20327
21928
  }
@@ -20356,7 +21957,7 @@ var CashuWallet = class {
20356
21957
  if (states.some((s) => s.state === "PENDING")) return "pending";
20357
21958
  const spent = new Set(inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
20358
21959
  if (spent.size > 0) console.warn(`[wallet] melt ${melt.quote} is unpaid, yet ${spent.size} of its proofs are spent`);
20359
- const lost = melt.outlay - inputs.filter((p) => !spent.has(p.secret)).reduce((sum, p) => sum + p.amount, 0);
21960
+ const lost = melt.outlay - inputs.reduce((sum, p) => sum + p.amount, 0);
20360
21961
  await transact([
20361
21962
  STORES.proofs,
20362
21963
  STORES.melts,
@@ -20371,6 +21972,10 @@ var CashuWallet = class {
20371
21972
  if (lost > 0) stores[STORES.walletTx].put(walletTx(melt.mint, "fee", 0, lost, melt.note ? `Payment failed: ${melt.note}` : "A Lightning payment failed"));
20372
21973
  });
20373
21974
  if (lost > 0) this.failedFees.set(melt.quote, lost);
21975
+ if (spent.size > 0) {
21976
+ this.spentAway.add(melt.quote);
21977
+ this.recheck(melt.mint).catch(() => {});
21978
+ }
20374
21979
  return "unpaid";
20375
21980
  }
20376
21981
  /** The fee a failed melt cost, once: for the words that say it failed. */
@@ -20399,14 +22004,22 @@ var CashuWallet = class {
20399
22004
  * and every invoice of theirs are deleted. A Lightning payment still in flight from one of them is not cut off: the
20400
22005
  * removal is refused until it settles.
20401
22006
  */
20402
- async forget(mints) {
22007
+ async forget(mints, acceptLoss = false) {
20403
22008
  const at = new Set(mints);
20404
22009
  if ((await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => at.has(m.mint))) throw new Error("A Lightning payment from this wallet is still in flight: wait for it to settle, then remove the wallet.");
22010
+ await this.pollSwaps(mints).catch(() => {});
22011
+ if ((await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => at.has(s.mint) && !s.released).length > 0 && !acceptLoss) throw new Error("An exchange of ecash with this wallet's mint is not finished yet: wait for the mint to answer, or confirm that what it would bring is lost to remove the wallet.");
20405
22012
  const proofs = (await this.allProofs()).filter((p) => at.has(p.mint));
20406
22013
  const quotes = (await wrap((await store(STORES.quotes, "readonly")).getAll())).filter((q) => at.has(q.mint));
20407
- await transact([STORES.proofs, STORES.quotes], (stores) => {
22014
+ const swaps = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => at.has(s.mint));
22015
+ await transact([
22016
+ STORES.proofs,
22017
+ STORES.quotes,
22018
+ STORES.swaps
22019
+ ], (stores) => {
20408
22020
  for (const p of proofs) stores[STORES.proofs].delete(p.secret);
20409
22021
  for (const q of quotes) stores[STORES.quotes].delete(q.quote);
22022
+ for (const s of swaps) stores[STORES.swaps].delete(s.id);
20410
22023
  });
20411
22024
  for (const mint of mints) {
20412
22025
  this.wallets.delete(mint);
@@ -20462,10 +22075,7 @@ var CashuWallet = class {
20462
22075
  async proofsAt(mint) {
20463
22076
  return (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved);
20464
22077
  }
20465
- /** After a swap: the inputs are spent, `keep` is what came back to us. One transaction, so it is all or nothing. */
20466
- async replaceProofs(mint, inputs, keep) {
20467
- await transact([STORES.proofs], (stores) => this.queueReplace(stores[STORES.proofs], mint, inputs, keep));
20468
- }
22078
+ /** After a swap: the inputs are spent, `keep` is what came back to us. Queued in the caller's transaction: all or nothing. */
20469
22079
  queueReplace(proofStore, mint, inputs, keep) {
20470
22080
  const kept = new Set(keep.map((p) => p.secret));
20471
22081
  for (const p of inputs) if (!kept.has(p.secret)) proofStore.delete(p.secret);
@@ -21147,7 +22757,11 @@ var PaymentDesk = class {
21147
22757
  }
21148
22758
  if (!payment?.token || payment.direction !== "out") throw new Error("Nothing to reclaim");
21149
22759
  try {
21150
- await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo);
22760
+ await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo, { payment: () => ({
22761
+ ...this.current(payment),
22762
+ state: "reclaimed",
22763
+ token: void 0
22764
+ }) });
21151
22765
  await this.save({
21152
22766
  ...this.current(payment),
21153
22767
  state: "reclaimed",
@@ -21458,31 +23072,18 @@ var PaymentDesk = class {
21458
23072
  addTestMint: true,
21459
23073
  payment: record
21460
23074
  });
21461
- await this.save(record(amount, mint));
21462
- const request = payment.requestId ? this.payments.get(payment.requestId) : void 0;
21463
- if (request?.kind === "request" && request.direction === "out" && this.owns(request, linkId) && request.state === "pending" && amount >= request.amount && (request.mints ?? []).includes(mint) && (!isWorthlessMint(mint) || (request.mints ?? []).every(isWorthlessMint))) {
21464
- if (request.group) await this.settleRequest(request, { mint });
21465
- else await this.save({
21466
- ...request,
21467
- state: "settled",
21468
- mint
23075
+ await this.creditCashu(record(amount, mint), link);
23076
+ } catch (error) {
23077
+ if (error instanceof SwapUnsettledError) return;
23078
+ const settled = this.payments.get(payment.id);
23079
+ if (settled && settled.linkId === linkId && settled.direction === "in" && settled.kind === "payment" && settled.state === "settled") {
23080
+ link?.sendPaymentResult({
23081
+ id: payment.id,
23082
+ ok: true,
23083
+ credited: String(settled.amount)
21469
23084
  });
23085
+ return;
21470
23086
  }
21471
- await this.host.storeMessage({
21472
- linkId,
21473
- id: `peer_${payment.timestamp}`,
21474
- text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
21475
- sender: "peer",
21476
- timestamp: payment.timestamp,
21477
- via: "datalink",
21478
- paymentId: payment.id
21479
- });
21480
- link?.sendPaymentResult({
21481
- id: payment.id,
21482
- ok: true,
21483
- credited: String(amount)
21484
- });
21485
- } catch (error) {
21486
23087
  const reason = error instanceof Error ? error.message : String(error);
21487
23088
  link?.sendPaymentResult({
21488
23089
  id: payment.id,
@@ -21502,6 +23103,61 @@ var PaymentDesk = class {
21502
23103
  } catch {}
21503
23104
  }
21504
23105
  }
23106
+ /**
23107
+ * A contact's ecash is in the wallet, and its record was written with it in the same transaction: the request it
23108
+ * pays is settled, the chat shows it, and the contact is told.
23109
+ */
23110
+ async creditCashu(record, link) {
23111
+ const { linkId, amount } = record;
23112
+ const mint = record.mint;
23113
+ await this.save(record);
23114
+ const request = record.requestId ? this.payments.get(record.requestId) : void 0;
23115
+ if (request?.kind === "request" && request.direction === "out" && this.owns(request, linkId) && request.state === "pending" && amount >= request.amount && (request.mints ?? []).includes(mint) && (!isWorthlessMint(mint) || (request.mints ?? []).every(isWorthlessMint))) {
23116
+ if (request.group) await this.settleRequest(request, { mint });
23117
+ else await this.save({
23118
+ ...request,
23119
+ state: "settled",
23120
+ mint
23121
+ });
23122
+ }
23123
+ await this.host.storeMessage({
23124
+ linkId,
23125
+ id: `peer_${record.createdAt}`,
23126
+ text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
23127
+ sender: "peer",
23128
+ timestamp: record.createdAt,
23129
+ via: "datalink",
23130
+ paymentId: record.id
23131
+ });
23132
+ link?.sendPaymentResult({
23133
+ id: record.id,
23134
+ ok: true,
23135
+ credited: String(amount)
23136
+ });
23137
+ }
23138
+ /**
23139
+ * A redeem the mint had not answered is settled, after the call that made it was over (`CashuWallet.pollSwaps`).
23140
+ * Done: the ecash and its payment record are already stored, and the rest follows as if the answer had come in time.
23141
+ * Not done: the mint never made it, so a contact's ecash is still the contact's, and it is told so.
23142
+ */
23143
+ async onSwapSettled(swap, done) {
23144
+ const record = swap.payment;
23145
+ if (!record) return;
23146
+ const link = this.host.getLink(record.linkId);
23147
+ if (record.direction === "out") {
23148
+ if (done) {
23149
+ this.payments.set(record.id, record);
23150
+ this.host.onChange();
23151
+ }
23152
+ return;
23153
+ }
23154
+ if (done) await this.creditCashu(record, link);
23155
+ else if (!this.payments.has(record.id)) link?.sendPaymentResult({
23156
+ id: record.id,
23157
+ ok: false,
23158
+ error: "The mint did not confirm the ecash"
23159
+ });
23160
+ }
21505
23161
  async onPaymentResult(linkId, result) {
21506
23162
  const payment = this.find(linkId, result.id);
21507
23163
  if (payment?.target?.method === "fedimint" && payment.linkId === linkId && payment.direction === "out" && payment.kind === "payment") {
@@ -23019,6 +24675,20 @@ function knockCue(before, after) {
23019
24675
  }
23020
24676
  /** A group's own line that says I made it or joined it (not someone else joining). */
23021
24677
  var groupCue = (message) => message.event === "created" || message.event === "joined" && !message.member ? "group" : void 0;
24678
+ /**
24679
+ * When a contact's message was written, by this device's clock. The time it says is its sender's clock, and a clock
24680
+ * behind this one makes everything it writes look old: a reply to what I just wrote read as written minutes before
24681
+ * my message, a late catch-up, and nothing rang. `leads`: for the contact's latest messages on the live session, how
24682
+ * far the time each said was from when it came (`sentAt - timestamp`). A message cannot come before it is written, so
24683
+ * the one that came quickest says how far behind the contact's clock is, and every time it says is read that much
24684
+ * later. A clock ahead needs nothing: a time still to come is taken as when the message came (as `shownTime` does).
24685
+ * With no live message to tell the clock by, the time is taken as said.
24686
+ */
24687
+ function writtenAt(message, leads) {
24688
+ if (message.sentAt === void 0) return message.timestamp;
24689
+ const behind = leads?.length ? Math.min(0, Math.max(...leads)) : 0;
24690
+ return Math.min(message.timestamp, message.sentAt - behind);
24691
+ }
23022
24692
  var JOIN_NOTICE = /^👋 (?:.+ )?joined$/;
23023
24693
  /** What a newly stored row says, or null when it is quiet. `type`: "message" for the contact's, "sent" for mine. */
23024
24694
  function messageAttention(type, message, { startedAt, newest, now }) {
@@ -23205,7 +24875,11 @@ var WalletSetup = class {
23205
24875
  * The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
23206
24876
  * never on the app's first load.
23207
24877
  */
23208
- /** n0's public relays, the same ones the Desktop's Iroh homes on by default. */
24878
+ /**
24879
+ * n0's public relays, the same ones the Desktop's Iroh homes on by default. Iroh itself names them with the trailing
24880
+ * dot of a full domain name (`use1-1.relay.n0.iroh.link.`), and compares relay URLs as text; here they have none,
24881
+ * because WebKit opens no host name that ends in a dot. `BROWSER_SPELLING` is what keeps the two one relay.
24882
+ */
23209
24883
  var DEFAULT_IROH_RELAYS = [
23210
24884
  "https://use1-1.relay.n0.iroh.link/",
23211
24885
  "https://euc1-1.relay.n0.iroh.link/",
@@ -23213,6 +24887,12 @@ var DEFAULT_IROH_RELAYS = [
23213
24887
  "https://usw1-1.relay.n0.iroh.link/"
23214
24888
  ];
23215
24889
  /**
24890
+ * The spelling of every relay URL handed to the wasm Iroh (`irohRelayUrl`): the relays it homes on and a contact's
24891
+ * relay before a dial. One spelling, so a contact on this endpoint's relay is reached over the connection already
24892
+ * open to it; without the dot, so Safari can open it (a Desktop's record names its relay with the dot).
24893
+ */
24894
+ var BROWSER_SPELLING = "plain";
24895
+ /**
23216
24896
  * Why a relay URL cannot be used, or null. TLS, except on this machine (a local `iroh-relay --dev`): a page
23217
24897
  * may not open plain WebSockets elsewhere, and the relay's operator would see traffic metadata in the clear.
23218
24898
  */
@@ -23310,7 +24990,7 @@ var IrohWebChannel = class {
23310
24990
  async function createIrohWebEndpoint(seedB64, options = {}) {
23311
24991
  const seed = fromBase64Url(seedB64);
23312
24992
  if (seed.length !== 32) throw new Error("Invalid transport seed");
23313
- const relays = [...options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS];
24993
+ const relays = [...new Set((options.relays?.length ? options.relays : DEFAULT_IROH_RELAYS).map((relay) => irohRelayUrl(relay, BROWSER_SPELLING)))];
23314
24994
  const node = await (await (options.load ?? loadIrohWasm)()).IrohNode.start(seed, relays, options.onlineMs ?? 1e4);
23315
24995
  const channels = /* @__PURE__ */ new Set();
23316
24996
  let stopped = false;
@@ -23340,7 +25020,7 @@ async function createIrohWebEndpoint(seedB64, options = {}) {
23340
25020
  },
23341
25021
  async connect(descriptor) {
23342
25022
  if (stopped) throw new Error("Iroh endpoint is stopped");
23343
- const conn = await node.connect(descriptor, options.connectMs ?? 2e4);
25023
+ const conn = await node.connect(irohDescriptor(descriptor, BROWSER_SPELLING), options.connectMs ?? 2e4);
23344
25024
  if (stopped) {
23345
25025
  conn.close();
23346
25026
  throw new Error("Iroh endpoint is stopped");
@@ -23448,6 +25128,8 @@ var SPARE_INVITE_WARM_AFTER_TAKE_MS = 3e4;
23448
25128
  */
23449
25129
  var STARTUP_QUIET_MS = 15e3;
23450
25130
  var SPARE_INVITE_MAX_AGE_MS = 9e5;
25131
+ /** How far before this clock a warm packet is dated (`warmKey`): further than a contact's clock is ever behind. */
25132
+ var WARM_DATED_BACK_MS = 864e5;
23451
25133
  /** How long a removal waits for a wallet's rail to claim what was already paid to it (tests shorten it). */
23452
25134
  var removalTiming = { claimMs: 3e4 };
23453
25135
  /** Why a removal needs the person's confirmation: what it holds, what it still waits for, and what confirming means. */
@@ -23520,12 +25202,34 @@ var GhostlyNode = class GhostlyNode {
23520
25202
  relays;
23521
25203
  /** Other profiles of this device, looked at for new messages (WISP 04 § Checking other profiles). */
23522
25204
  profilePeek;
25205
+ /** Whether this device's WebRTC attempts say direct connections are blocked on its network (`directPath.ts`). */
25206
+ directPath = new DirectPathWatch(() => {
25207
+ if (!this.shuttingDown) this.emitState();
25208
+ });
25209
+ /** Whether this device's clock seems to be off, from what the relays' answers and several contacts' packets say (`clockWatch.ts`). */
25210
+ clock = new ClockWatch(() => {
25211
+ if (!this.shuttingDown) this.emitState();
25212
+ });
25213
+ clockOff;
25214
+ /**
25215
+ * Edges of groups whose WebRTC did not connect in this run of the app (`edgeWithoutRtc`): they go on as an app with
25216
+ * no WebRTC does, over a native transport. Not kept: the next start tries WebRTC first again.
25217
+ */
25218
+ edgeRtcOff = /* @__PURE__ */ new Set();
23523
25219
  pollIntervals;
23524
25220
  localFetch;
23525
25221
  links = /* @__PURE__ */ new Map();
23526
25222
  services = [];
23527
25223
  activeLinkId = null;
23528
- nativeQueue = Promise.resolve();
25224
+ /**
25225
+ * Native listeners start one at a time per transport (`ensureNativeEndpoints`): their slots are counted per transport.
25226
+ * Not one queue for all: a transport slow to start held every other chat's listeners of every kind behind it.
25227
+ */
25228
+ nativeQueues = /* @__PURE__ */ new Map();
25229
+ /** Every listener start asked for so far has ended. */
25230
+ get nativeQueue() {
25231
+ return Promise.all(this.nativeQueues.values()).then(() => {});
25232
+ }
23529
25233
  shuttingDown = false;
23530
25234
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
23531
25235
  transfersRestored = false;
@@ -23551,11 +25255,26 @@ var GhostlyNode = class GhostlyNode {
23551
25255
  cueFeedback({ cue, key }, linkId) {
23552
25256
  this.feedback("cue", cue + ":" + key, linkId, false, cue);
23553
25257
  }
23554
- /** The newest time stored in each chat while this engine runs: a message landing well behind it is a catch-up. */
25258
+ /** The newest time written in each chat while this engine runs: a message written well before it is a catch-up. */
23555
25259
  newestAt = /* @__PURE__ */ new Map();
23556
- /** Only a new message at the end of its chat, or mine going out, says anything (engine/attention.ts). */
25260
+ /** The last place given in each chat while this engine runs (`arrivalKey`). */
25261
+ placedAt = /* @__PURE__ */ new Map();
25262
+ /**
25263
+ * Each 1:1 chat's contact: how far the time its latest live messages said was from when they came (`CLOCK_SAMPLES`
25264
+ * of them). What tells a contact's clock that is behind from a message that is late (`writtenAt`).
25265
+ */
25266
+ clockLeads = /* @__PURE__ */ new Map();
25267
+ /** A contact's row came on the live session of a 1:1 chat: what it says of the contact's clock is kept. */
25268
+ clockSample(message) {
25269
+ if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink" || message.linkId.startsWith("group:")) return;
25270
+ this.clockLeads.set(message.linkId, [...this.clockLeads.get(message.linkId) ?? [], message.sentAt - message.timestamp].slice(-5));
25271
+ }
25272
+ /** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
23557
25273
  messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
23558
- const attention = messageAttention(type, message, {
25274
+ const attention = messageAttention(type, {
25275
+ ...message,
25276
+ timestamp: writtenAt(message, this.clockLeads.get(message.linkId))
25277
+ }, {
23559
25278
  startedAt: this.feedbackStartedAt,
23560
25279
  newest,
23561
25280
  now: Date.now()
@@ -23606,6 +25325,11 @@ var GhostlyNode = class GhostlyNode {
23606
25325
  deleted: (linkId, messageId) => !!this.links.get(linkId)?.stored.deletedIds?.includes(messageId),
23607
25326
  messageExists: (linkId, messageId) => db.hasMessage(linkId, messageId),
23608
25327
  storeMessage: (message) => this.storeMessage(message),
25328
+ place: (linkId) => {
25329
+ const place = arrivalKey(Date.now(), this.placedAt.get(linkId));
25330
+ this.placedAt.set(linkId, place);
25331
+ return place;
25332
+ },
23609
25333
  transfers: this.transfers,
23610
25334
  changed: (delayMs) => this.emitState(delayMs),
23611
25335
  flush: () => this.flushState(),
@@ -23772,7 +25496,8 @@ var GhostlyNode = class GhostlyNode {
23772
25496
  paymentId: melt.paymentId,
23773
25497
  mint: melt.mint
23774
25498
  }),
23775
- onTestMintNeeded: async (mint) => void await this.walletAddMint({ url: mint })
25499
+ onTestMintNeeded: async (mint) => void await this.walletAddMint({ url: mint }),
25500
+ onSwapSettled: (swap, done) => this.desk.onSwapSettled(swap, done).then(() => swap.payment?.direction === "out" ? this.reviewTakenBack(swap.payment.id) : void 0)
23776
25501
  }, () => this.settings.mints);
23777
25502
  registry;
23778
25503
  providers() {
@@ -23827,7 +25552,7 @@ var GhostlyNode = class GhostlyNode {
23827
25552
  const stored = this.links.get(linkId)?.stored;
23828
25553
  return stored?.group && !stored.groupEntry ? stored.group : parsePayLink(linkId)?.groupId;
23829
25554
  },
23830
- groupLinks: (groupId) => this.groups.isCommunityGroup(groupId) ? [groupLinkId(groupId)] : [...this.groupEdges(groupId).values()],
25555
+ groupLinks: (groupId) => this.groups.isCommunityGroup(groupId) ? [groupLinkId(groupId)] : [...this.memberEdges(groupId).values()],
23831
25556
  storeMessage: (message) => this.storeMessage(message),
23832
25557
  heldPaymentMethods: (linkId) => {
23833
25558
  const live = this.links.get(linkId);
@@ -24253,11 +25978,14 @@ var GhostlyNode = class GhostlyNode {
24253
25978
  /** Private groups (WISP 900): sessions, admission on contact chats, and the pairwise edges that carry them. */
24254
25979
  groups = new Groups({
24255
25980
  sendOnLink: (linkId, frame) => {
24256
- const link = this.links.get(linkId)?.link;
24257
- if (!link) throw new Error("You are offline");
24258
- link.sendGroupFrame(frame);
25981
+ const live = this.links.get(linkId);
25982
+ if (!live?.link) throw new Error("You are offline");
25983
+ const { group, groupPeer, groupEntry } = live.stored;
25984
+ if (group && groupPeer && !groupEntry && !this.groups.edgeAllows(group, groupPeer, frame)) return;
25985
+ live.link.sendGroupFrame(frame);
24259
25986
  },
24260
25987
  linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
25988
+ linkOpen: (linkId) => !!this.links.get(linkId)?.link?.isDataLinkOpen,
24261
25989
  myNick: () => this.sharedNick,
24262
25990
  staysOnline: () => this.options.staysOnline ?? (this.options.platform === "desktop" && typeof RTCPeerConnection !== "undefined"),
24263
25991
  peerRoom: (groupId) => {
@@ -24289,7 +26017,7 @@ var GhostlyNode = class GhostlyNode {
24289
26017
  },
24290
26018
  linkBack: (linkId) => {
24291
26019
  const live = this.links.get(linkId);
24292
- return !!live?.lastSyncAt && !!live.presence?.online && live.presence.lastPacketAt > live.lastSyncAt;
26020
+ return !!live?.lastSyncAt && !!live.presence?.online && presenceSeenAt(live.presence) > live.lastSyncAt;
24293
26021
  },
24294
26022
  publish: (identity, records, background) => this.groupTransport.publish(identity, records, { background }),
24295
26023
  resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
@@ -24376,7 +26104,7 @@ var GhostlyNode = class GhostlyNode {
24376
26104
  }
24377
26105
  /** The link payments with `linkId` go over: a chat's or an edge's, or a community member's through the group. */
24378
26106
  paymentLink(linkId) {
24379
- return this.links.get(linkId)?.link ?? this.communityPay.link(linkId);
26107
+ return this.outsideEdge(linkId) ? null : this.links.get(linkId)?.link ?? this.communityPay.link(linkId);
24380
26108
  }
24381
26109
  /**
24382
26110
  * Payments in groups (WISP 9xx § Payments): the money goes over the edge to one member through the desk, like a
@@ -24395,7 +26123,7 @@ var GhostlyNode = class GhostlyNode {
24395
26123
  member: stored.groupPeer
24396
26124
  } : void 0;
24397
26125
  },
24398
- edges: (groupId) => this.groups.isCommunityGroup(groupId) ? /* @__PURE__ */ new Map([["*", groupLinkId(groupId)]]) : this.groupEdges(groupId),
26126
+ edges: (groupId) => this.groups.isCommunityGroup(groupId) ? /* @__PURE__ */ new Map([["*", groupLinkId(groupId)]]) : this.memberEdges(groupId),
24399
26127
  membership: (groupId) => this.membership(groupId),
24400
26128
  send: (linkId, frame) => {
24401
26129
  const pay = parsePayLink(linkId);
@@ -24409,17 +26137,56 @@ var GhostlyNode = class GhostlyNode {
24409
26137
  },
24410
26138
  messages: (groupId) => db.getMessages(`group:${groupId}`),
24411
26139
  putMessage: async (message) => {
24412
- await db.putMessage(message);
26140
+ const kept = await db.getMessage(message.linkId, message.id);
26141
+ await db.putMessage(kept ? {
26142
+ ...message,
26143
+ timestamp: kept.timestamp,
26144
+ ...kept.sentAt !== void 0 && { sentAt: kept.sentAt }
26145
+ } : this.placed(message));
24413
26146
  await this.messagesChanged(message.linkId, [message.id]);
24414
26147
  this.emitState();
24415
26148
  }
24416
26149
  });
24417
- /** Member key → edge link id, for the edges of a group that exist (open or not). */
26150
+ /**
26151
+ * Member key → edge link id, for the edges of a group that exist (open or not), whoever is at the other end: an edge
26152
+ * outlives its member's place in the roster for a moment, or for as long as it is kept to tell it (`StoredGroup.farewells`).
26153
+ * For the group engine alone, which opens and closes them. Whatever is said to the group goes over `memberEdges`.
26154
+ */
24418
26155
  groupEdges(groupId) {
24419
26156
  const edges = /* @__PURE__ */ new Map();
24420
26157
  for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer && !live.stored.groupEntry) edges.set(live.stored.groupPeer, live.stored.id);
24421
26158
  return edges;
24422
26159
  }
26160
+ /** The edges of a private group to its members as the roster has them now: the only ones the group's traffic goes over. */
26161
+ memberEdges(groupId) {
26162
+ const edges = this.groupEdges(groupId);
26163
+ for (const key of [...edges.keys()]) if (!this.groups.inRoster(groupId, key)) edges.delete(key);
26164
+ return edges;
26165
+ }
26166
+ /** An edge of a private group to someone its roster no longer has: it says no name and no picture of mine. */
26167
+ quietEdge(stored) {
26168
+ return !!stored && this.meshEdge(stored) && this.groups.outsider(stored.group, stored.groupPeer);
26169
+ }
26170
+ /** Edges that said no name while their other end was out of the roster: the name is said again once it is back in. */
26171
+ quietEdges = /* @__PURE__ */ new Set();
26172
+ /** A group's roster changed: an edge kept to someone now out of it (closed, waiting for them) stops carrying my name. */
26173
+ quietOutsideEdges(groupId) {
26174
+ for (const live of this.links.values()) {
26175
+ if (live.stored.group !== groupId || !live.link || !this.meshEdge(live.stored)) continue;
26176
+ const id = live.stored.id;
26177
+ if (this.quietEdge(live.stored)) {
26178
+ if (!this.quietEdges.has(id) && !live.link.isDataLinkOpen) {
26179
+ this.quietEdges.add(id);
26180
+ live.link.setNick(void 0);
26181
+ }
26182
+ } else if (this.quietEdges.delete(id)) live.link.setNick(this.sharedNick);
26183
+ }
26184
+ }
26185
+ /** An edge of a private group whose other end is not in its roster: nothing of the group goes over it but what the group engine says. */
26186
+ outsideEdge(linkId) {
26187
+ const stored = this.links.get(linkId)?.stored;
26188
+ return !!stored && this.meshEdge(stored) && !this.groups.inRoster(stored.group, stored.groupPeer);
26189
+ }
24423
26190
  constructor(events, options = {}) {
24424
26191
  this.events = events;
24425
26192
  this.options = options;
@@ -24436,6 +26203,7 @@ var GhostlyNode = class GhostlyNode {
24436
26203
  this.localFetch = options.localFetch ?? webLocalFetch;
24437
26204
  this.hold.host.transport = this.transport;
24438
26205
  this.transport.subscribe?.(() => this.emitState());
26206
+ this.clockOff = this.transport.onServerTime?.((time) => this.clock.server(time.source, time.date, time.sent, time.received));
24439
26207
  }
24440
26208
  /** The Desktop reaches the DHT itself: the relays in Settings are written to, and read from only when the person chose so. */
24441
26209
  configureDirect() {
@@ -24519,6 +26287,10 @@ var GhostlyNode = class GhostlyNode {
24519
26287
  networks[network] = {
24520
26288
  mints: this.withWaits(view.mints, awaiting.mints),
24521
26289
  balance: view.balance,
26290
+ setAside: view.setAside,
26291
+ openSwaps: view.openSwaps,
26292
+ swapsAmount: view.swapsAmount,
26293
+ unconfirmed: view.unconfirmed,
24522
26294
  history,
24523
26295
  feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0),
24524
26296
  ark: this.arkWallets[network].view,
@@ -24659,6 +26431,7 @@ var GhostlyNode = class GhostlyNode {
24659
26431
  });
24660
26432
  const history = /* @__PURE__ */ new Map();
24661
26433
  for (const stored of await db.getLinks()) {
26434
+ if (!stored.group) await this.settleHistory(stored.id);
24662
26435
  const messages = stored.group ? [] : await db.getMessages(stored.id);
24663
26436
  const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
24664
26437
  const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
@@ -24671,11 +26444,13 @@ var GhostlyNode = class GhostlyNode {
24671
26444
  const files = linkFilesFrom(stored.id, storedFiles, messages);
24672
26445
  this.links.set(stored.id, newLiveLink(stored, messages[messages.length - 1]?.timestamp ?? 0, files));
24673
26446
  history.set(stored.id, messages);
26447
+ for (const message of messages.slice(-20)) this.clockSample(message);
24674
26448
  const note = latestReaction(messages);
24675
26449
  if (note) this.reactionNotes.set(stored.id, note);
24676
26450
  if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
24677
26451
  }
24678
26452
  this.transfersRestored = true;
26453
+ for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
24679
26454
  await this.groups.load();
24680
26455
  for (const group of this.groups.views()) {
24681
26456
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
@@ -24738,6 +26513,9 @@ var GhostlyNode = class GhostlyNode {
24738
26513
  async shutdown() {
24739
26514
  this.shuttingDown = true;
24740
26515
  this.depart();
26516
+ this.directPath.close();
26517
+ this.clock.close();
26518
+ this.clockOff?.();
24741
26519
  if (this.relayRetry) clearTimeout(this.relayRetry);
24742
26520
  if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
24743
26521
  for (const timer of this.resumeTimers.values()) clearTimeout(timer);
@@ -24806,6 +26584,8 @@ var GhostlyNode = class GhostlyNode {
24806
26584
  ...this.transport.configure && { direct: true },
24807
26585
  ...this.transport.discovery && { discovery: this.transport.discovery() },
24808
26586
  ...typeof RTCPeerConnection === "undefined" && { webrtc: false },
26587
+ ...this.directPath.blocked && { directBlocked: true },
26588
+ ...this.clock.offset !== null && { clockOffMs: this.clock.offset },
24809
26589
  ...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
24810
26590
  },
24811
26591
  links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
@@ -24886,6 +26666,22 @@ var GhostlyNode = class GhostlyNode {
24886
26666
  before: from
24887
26667
  });
24888
26668
  }
26669
+ async statusCardIndex() {
26670
+ const rows = [];
26671
+ for (const m of await db.getCardMessages()) {
26672
+ if (m.card?.kind !== "task" && m.card?.kind !== "routine") continue;
26673
+ rows.push({
26674
+ linkId: m.linkId,
26675
+ id: m.id,
26676
+ card: m.card,
26677
+ sender: m.sender,
26678
+ ...m.member && { member: m.member },
26679
+ timestamp: m.timestamp,
26680
+ ...m.edit && { editedAt: m.edit.at }
26681
+ });
26682
+ }
26683
+ return rows;
26684
+ }
24889
26685
  async createLink() {
24890
26686
  const { mine, inviteCode } = this.takeInvite();
24891
26687
  return {
@@ -24913,6 +26709,16 @@ var GhostlyNode = class GhostlyNode {
24913
26709
  inviteCode: spare.inviteCode
24914
26710
  };
24915
26711
  }
26712
+ /**
26713
+ * Warms a key with an empty packet (`emptyLinkRecords`), dated `WARM_DATED_BACK_MS` before this clock. One of the two
26714
+ * keys an invite warms is the contact's: its own first packet has to be the later one, or the relays and the DHT keep
26715
+ * this empty one in its place. Dated by this clock, it was later than the first packets of every contact whose clock
26716
+ * runs behind this one: a joiner two minutes behind stayed unseen for two minutes, one an hour behind for an hour.
26717
+ */
26718
+ warmKey(identity) {
26719
+ const records = emptyLinkRecords();
26720
+ (this.transport.publishPayload ? this.transport.publishPayload(identity.pubKeyZ32, createRelayPayload(identity, records, BigInt(Date.now() - WARM_DATED_BACK_MS) * 1000n)) : this.transport.publish(identity, records)).catch(() => {});
26721
+ }
24916
26722
  makeSpare() {
24917
26723
  const { mine, invite, inviteCode } = createChatInvite();
24918
26724
  return {
@@ -24938,7 +26744,7 @@ var GhostlyNode = class GhostlyNode {
24938
26744
  spare.warmedAt ??= Date.now();
24939
26745
  for (const identity of [identityFromSeedB64(spare.mine.seedB64), spare.inviteKey]) {
24940
26746
  this.warmedKeys.set(identity.pubKeyZ32, Date.now());
24941
- this.transport.publish(identity, emptyLinkRecords()).catch(() => {});
26747
+ this.warmKey(identity);
24942
26748
  }
24943
26749
  this.spareTimer = setTimeout(() => {
24944
26750
  this.spareTimer = null;
@@ -25241,6 +27047,7 @@ var GhostlyNode = class GhostlyNode {
25241
27047
  * (a group on hubs dropped it) and is no longer in the group, or a group I left or forgot, replaces it.
25242
27048
  */
25243
27049
  groupMembersChanged(groupId) {
27050
+ this.quietOutsideEdges(groupId);
25244
27051
  const holders = this.settings.wakeHeldBy?.[groupId];
25245
27052
  if (!holders?.length) return;
25246
27053
  const members = this.membership(groupId)?.members;
@@ -25255,6 +27062,7 @@ var GhostlyNode = class GhostlyNode {
25255
27062
  groupHistoryGone(groupId) {
25256
27063
  const linkId = `group:${groupId}`;
25257
27064
  this.newestAt.delete(linkId);
27065
+ this.placedAt.delete(linkId);
25258
27066
  this.reactionNotes.delete(linkId);
25259
27067
  this.events.onMessages(linkId, []);
25260
27068
  }
@@ -25295,7 +27103,7 @@ var GhostlyNode = class GhostlyNode {
25295
27103
  async shareGroupWake(linkId) {
25296
27104
  const live = this.links.get(linkId);
25297
27105
  const own = this.settings.wake;
25298
- if (!live?.link?.groupsSupport || !this.meshEdge(live.stored)) return;
27106
+ if (!live?.link?.groupsSupport || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
25299
27107
  if (this.settings.wakeMutedGroups?.includes(live.stored.group)) {
25300
27108
  this.sendGroupWake(live, null);
25301
27109
  return;
@@ -25319,7 +27127,7 @@ var GhostlyNode = class GhostlyNode {
25319
27127
  /** A `group-wake` frame on an edge: the member it is pinned to shares how to wake it, or says to forget it. */
25320
27128
  receiveGroupWake(linkId, frame) {
25321
27129
  const live = this.links.get(linkId);
25322
- if (!live || !this.meshEdge(live.stored)) return;
27130
+ if (!live || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
25323
27131
  let window = this.groupWakeReceived.get(linkId);
25324
27132
  if (!window) this.groupWakeReceived.set(linkId, window = new RateWindow(6, 6e4));
25325
27133
  if (!window.take()) return;
@@ -25338,7 +27146,7 @@ var GhostlyNode = class GhostlyNode {
25338
27146
  */
25339
27147
  wakeMentioned(groupId, text, mentions) {
25340
27148
  if (!mentions.length || !this.settings.online || this.groups.isCommunityGroup(groupId)) return;
25341
- const edges = this.groupEdges(groupId);
27149
+ const edges = this.memberEdges(groupId);
25342
27150
  const wakes = groupWakes({
25343
27151
  group: groupId,
25344
27152
  mentions,
@@ -25474,7 +27282,10 @@ var GhostlyNode = class GhostlyNode {
25474
27282
  * (`network`): relays left alone for failing on the old network are asked again at once.
25475
27283
  */
25476
27284
  wake(params = {}) {
25477
- if (params.network) this.transport.networkChanged?.();
27285
+ if (params.network) {
27286
+ this.transport.networkChanged?.();
27287
+ this.directPath.reset();
27288
+ }
25478
27289
  for (const live of this.links.values()) live.link?.wake();
25479
27290
  this.hold.wake();
25480
27291
  for (const network of WALLET_NETWORKS) {
@@ -25519,7 +27330,7 @@ var GhostlyNode = class GhostlyNode {
25519
27330
  };
25520
27331
  if (!live?.link) return { error: "You are offline" };
25521
27332
  if (!trimmed) return { error: null };
25522
- const timestamp = params.timestamp ?? Date.now();
27333
+ const timestamp = params.timestamp ?? arrivalKey(Date.now(), this.placedAt.get(linkId));
25523
27334
  if (live.stored.profile) live.link.setTyping(false);
25524
27335
  if (live.stored.profile) {
25525
27336
  const found = params.replyTo === void 0 ? void 0 : await this.replyFor(linkId, params.replyTo);
@@ -25590,6 +27401,7 @@ var GhostlyNode = class GhostlyNode {
25590
27401
  async sendChatText(live, trimmed, timestamp, preview, reply, forwarded, card) {
25591
27402
  const { link } = live, linkId = live.stored.id;
25592
27403
  if (!link) return { error: "You are offline" };
27404
+ this.chatInUse(live);
25593
27405
  const bytes = new TextEncoder().encode(trimmed).length;
25594
27406
  if (bytes > LIMITS.maxChatMessageBytes) return {
25595
27407
  error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`,
@@ -25841,7 +27653,7 @@ var GhostlyNode = class GhostlyNode {
25841
27653
  if (!takesPeerEdit(current, edit.m) || (current.edit?.seq ?? 0) >= edit.e) return null;
25842
27654
  const next = withEdit(current, {
25843
27655
  seq: edit.e,
25844
- at: receivedTimestamp(edit.ts),
27656
+ at: heardTime(edit.ts),
25845
27657
  text: edit.m,
25846
27658
  preview: edit.pv,
25847
27659
  card: edit.sc
@@ -25983,6 +27795,7 @@ var GhostlyNode = class GhostlyNode {
25983
27795
  outboxFor(linkId) {
25984
27796
  let outbox = this.outboxes.get(linkId);
25985
27797
  if (!outbox) {
27798
+ const chat = this.links.get(linkId)?.stored;
25986
27799
  outbox = new Outbox({
25987
27800
  read: () => db.getMessages(linkId),
25988
27801
  update: async (id, delivery, error, extra) => {
@@ -26012,10 +27825,12 @@ var GhostlyNode = class GhostlyNode {
26012
27825
  }, { resender: {
26013
27826
  ready: (message) => {
26014
27827
  const live = this.links.get(linkId), link = live?.link;
27828
+ if (message.file && !message.wireId) return !!link?.isDataLinkOpen && link.textDelivery === "stream";
26015
27829
  if (message.via === "pkarr" && !GhostlyNode.dhtByChoice(live) && link?.textDelivery !== "stream") return false;
26016
27830
  return !!link?.canSendText && !!message.wireId && !link.validateText(message.text, message.timestamp, message.wireId, GhostlyNode.wireReply(message));
26017
27831
  },
26018
27832
  requeueExpired: () => !GhostlyNode.dhtByChoice(this.links.get(linkId)),
27833
+ ...!!chat?.profile && !chat.group && { sendFile: (message) => this.sendWaitingFile(linkId, message) },
26019
27834
  via: (message) => {
26020
27835
  const delivery = this.links.get(linkId)?.link?.textDelivery;
26021
27836
  return delivery === "dht" ? "pkarr" : delivery === "stream" ? "datalink" : message.via;
@@ -26390,12 +28205,19 @@ var GhostlyNode = class GhostlyNode {
26390
28205
  */
26391
28206
  transferFile(live, file, wireId, timestamp, fail, reply, forwarded) {
26392
28207
  const { link } = live;
26393
- if (!link) return fail("You are offline");
28208
+ if (!link) {
28209
+ fail("You are offline");
28210
+ return Promise.resolve();
28211
+ }
26394
28212
  this.transfers.set(file.id, {
26395
28213
  state: "transferring",
26396
28214
  transferred: 0,
26397
28215
  size: file.size
26398
28216
  });
28217
+ let said = () => {};
28218
+ const offered = new Promise((resolve) => {
28219
+ said = resolve;
28220
+ });
26399
28221
  (async () => {
26400
28222
  if (!reply || !forwarded) {
26401
28223
  const message = (await db.getMessages(live.stored.id)).find((m) => m.id === `me_${timestamp}`);
@@ -26427,6 +28249,7 @@ var GhostlyNode = class GhostlyNode {
26427
28249
  size: file.size
26428
28250
  });
26429
28251
  const source = streamStored(stored);
28252
+ said();
26430
28253
  await link.sendFile({
26431
28254
  id: wireId,
26432
28255
  name: file.name,
@@ -26439,7 +28262,8 @@ var GhostlyNode = class GhostlyNode {
26439
28262
  ...reply && { reply },
26440
28263
  ...forwarded && { forwarded }
26441
28264
  }, source);
26442
- })().catch((error) => fail(error instanceof Error ? error.message : String(error)));
28265
+ })().catch((error) => fail(error instanceof Error ? error.message : String(error))).finally(said);
28266
+ return offered;
26443
28267
  }
26444
28268
  fileAction({ linkId, fileId, action }) {
26445
28269
  const live = this.links.get(linkId);
@@ -26468,13 +28292,39 @@ var GhostlyNode = class GhostlyNode {
26468
28292
  return link.supportsLargeFiles;
26469
28293
  }
26470
28294
  /**
26471
- * The chat is live: what waited for it goes now, in the order it was written. A file for an app that turns
26472
- * out not to take files fails with that reason instead of waiting forever (WISP 03).
28295
+ * A file that waited for the chat to be live goes: said on the open session (see `transferFile`), and this returns
28296
+ * then, so the chat's outbox sends it in its place among the texts that waited (WISP 400, requirement 10). A file
28297
+ * for an app that turns out not to take files fails with that reason instead of waiting forever (WISP 03).
28298
+ */
28299
+ async sendWaitingFile(linkId, message) {
28300
+ const live = this.links.get(linkId), file = message.file;
28301
+ if (!live?.link?.isDataLinkOpen || !file) return;
28302
+ if (!GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
28303
+ if (!live.link.isDataLinkOpen) return;
28304
+ if (!GhostlyNode.takesFiles(live.link)) await db.updateDelivery(linkId, message.id, "failed", "Your contact's app cannot receive files.");
28305
+ else {
28306
+ await db.putMessage(sentNow(message));
28307
+ await this.transferFile(live, file, file.id.slice(`${linkId}-out-`.length), message.timestamp, (error) => {
28308
+ this.transfers.set(file.id, {
28309
+ state: "failed",
28310
+ transferred: 0,
28311
+ size: file.size,
28312
+ error
28313
+ });
28314
+ this.emitState();
28315
+ }, GhostlyNode.wireReply(message));
28316
+ }
28317
+ await this.messagesChanged(linkId, [message.id]);
28318
+ }
28319
+ /**
28320
+ * The chat is live: what waited for it goes now, in the order it was written. A 1:1 chat's files go with its texts,
28321
+ * through its outbox (`sendWaitingFile`); here its payment requests, and everything of any other chat.
26473
28322
  */
26474
28323
  async sendWaiting(linkId) {
26475
28324
  const live = this.links.get(linkId);
26476
28325
  if (!live?.link?.isDataLinkOpen) return;
26477
- const waiting = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && m.delivery === "waiting" && (m.file || m.paymentId)).sort((a, b) => a.timestamp - b.timestamp);
28326
+ const withTexts = this.outboxFor(linkId).sendsFiles;
28327
+ const waiting = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && m.delivery === "waiting" && (m.file && !withTexts || m.paymentId)).sort((a, b) => a.timestamp - b.timestamp);
26478
28328
  if (waiting.some((m) => m.file) && !GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
26479
28329
  for (const message of waiting) if (message.file) {
26480
28330
  if (!GhostlyNode.takesFiles(live.link)) {
@@ -26833,7 +28683,9 @@ var GhostlyNode = class GhostlyNode {
26833
28683
  }
26834
28684
  async createGroup({ name, profile }) {
26835
28685
  if (typeof name !== "string" || !name.trim()) throw new Error("Give the group a name");
26836
- return { groupId: await this.groups.create(name.trim().slice(0, 48), profile === "mesh" ? "mesh" : "community") };
28686
+ const clean = groupName(name);
28687
+ if (!clean) throw new Error(`A group's name is 1 to 64 characters on one line`);
28688
+ return { groupId: await this.groups.create(clean, profile === "mesh" ? "mesh" : "community") };
26837
28689
  }
26838
28690
  inviteToGroup({ groupId, linkId }) {
26839
28691
  if (!this.links.get(linkId)?.stored.profile || this.links.get(linkId)?.stored.group) throw new Error("Invite a paired contact");
@@ -27044,7 +28896,10 @@ var GhostlyNode = class GhostlyNode {
27044
28896
  if (typeof linkId !== "string" || !linkId) return { error: "No chat to react in" };
27045
28897
  if (linkId.startsWith("group:")) {
27046
28898
  const groupId = linkId.slice(6);
27047
- if (!this.membership(groupId)) return { error: "You are not in this group" };
28899
+ if (!this.membership(groupId)) return {
28900
+ error: this.groups.views().find((g) => g.id === groupId)?.statusReason ?? "You are not in this group",
28901
+ refused: true
28902
+ };
27048
28903
  const result = await this.reactions.mine(linkId, messageId, emoji);
27049
28904
  return "error" in result ? result : this.sendGroupReaction(groupId, result.reaction);
27050
28905
  }
@@ -27131,13 +28986,14 @@ var GhostlyNode = class GhostlyNode {
27131
28986
  ...wireReaction(reaction),
27132
28987
  ...this.groups.signReaction(groupId, wireReaction(reaction))
27133
28988
  };
27134
- for (const edge of this.groupEdges(groupId).values()) try {
28989
+ for (const edge of this.memberEdges(groupId).values()) try {
27135
28990
  this.links.get(edge)?.link?.sendGroupFrame(frame);
27136
28991
  } catch {}
27137
28992
  return { error: null };
27138
28993
  }
27139
28994
  /** An edge of a private group opened: the member hears my latest reactions again, in case it missed them. */
27140
28995
  async resendGroupReactions(groupId, linkId) {
28996
+ if (this.outsideEdge(linkId)) return;
27141
28997
  const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
27142
28998
  for (const reaction of mine) try {
27143
28999
  this.links.get(linkId)?.link?.sendGroupFrame({
@@ -27197,7 +29053,7 @@ var GhostlyNode = class GhostlyNode {
27197
29053
  k: frame.k,
27198
29054
  sig: frame.sig
27199
29055
  });
27200
- for (const member of this.groupEdges(groupId).keys()) this.sendGroupPinFrame(groupId, member);
29056
+ for (const member of this.memberEdges(groupId).keys()) this.sendGroupPinFrame(groupId, member);
27201
29057
  return { error: null };
27202
29058
  }
27203
29059
  const live = this.links.get(linkId);
@@ -27282,11 +29138,11 @@ var GhostlyNode = class GhostlyNode {
27282
29138
  sig: via.frame.sig
27283
29139
  }
27284
29140
  }) || !via) return;
27285
- for (const key of this.groupEdges(groupId).keys()) if (key !== via.from && key !== member) this.sendGroupPinFrame(groupId, key);
29141
+ for (const key of this.memberEdges(groupId).keys()) if (key !== via.from && key !== member) this.sendGroupPinFrame(groupId, key);
27286
29142
  }
27287
29143
  /** A private group's pin, as its pinner signed it, over the edge to `member`; nothing when it is down (said when it opens). */
27288
29144
  sendGroupPinFrame(groupId, member) {
27289
- const pin = this.groups.pinOf(groupId), edge = this.groupEdges(groupId).get(member);
29145
+ const pin = this.groups.pinOf(groupId), edge = this.memberEdges(groupId).get(member);
27290
29146
  if (!pin?.k || !pin.sig || !edge || this.groups.isCommunityGroup(groupId) || !pinNumberHolds(pin.n)) return;
27291
29147
  const frame = {
27292
29148
  t: "group-pin",
@@ -27497,6 +29353,13 @@ var GhostlyNode = class GhostlyNode {
27497
29353
  if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
27498
29354
  if (network !== "mainnet" && network !== "testnet") throw new Error("Choose Mainnet or Testnet");
27499
29355
  await this.lightnings[network].start();
29356
+ if (type === "cashu") {
29357
+ let timer;
29358
+ await Promise.race([this.wallet.settleSwaps(this.networkMints(network)).catch(() => {}), new Promise((resolve) => {
29359
+ timer = setTimeout(resolve, removalTiming.claimMs);
29360
+ })]);
29361
+ clearTimeout(timer);
29362
+ }
27500
29363
  await this.refreshWallet();
27501
29364
  const card = type === "lightning" ? asked ?? this.lightnings[network].receivingId : void 0;
27502
29365
  const lightningName = card !== void 0 ? this.walletView.networks?.[network].lightnings?.find((c) => c.card === card)?.name : void 0;
@@ -27515,7 +29378,7 @@ var GhostlyNode = class GhostlyNode {
27515
29378
  try {
27516
29379
  if (type === "cashu") {
27517
29380
  const mints = this.networkMints(network);
27518
- await this.wallet.forget(mints);
29381
+ await this.wallet.forget(mints, acceptLoss === true);
27519
29382
  await this.updateSettings({ settings: { mints: this.settings.mints.filter((m) => !mints.includes(m)) } });
27520
29383
  } else if (type === "arkade") await this.arkWallets[network].remove();
27521
29384
  else if (type === "bark") await this.barkWallets[network].remove();
@@ -28195,8 +30058,32 @@ var GhostlyNode = class GhostlyNode {
28195
30058
  payRequest(params) {
28196
30059
  return this.desk.payRequest(params);
28197
30060
  }
28198
- reclaimPayment({ paymentId }) {
28199
- return this.desk.reclaim(paymentId);
30061
+ async reclaimPayment({ paymentId }) {
30062
+ await this.desk.reclaim(paymentId);
30063
+ await this.reviewTakenBack(paymentId);
30064
+ }
30065
+ /**
30066
+ * A reviewed payment whose ecash this wallet took back: its review ends failed, at once. The mint reads that ecash
30067
+ * spent (this wallet redeemed it), which the review would otherwise take for the contact being paid, and a request
30068
+ * whose review reads paid can never be reviewed again. Only when the ecash is back for certain (`reclaimed`).
30069
+ */
30070
+ async reviewTakenBack(id) {
30071
+ if (this.desk.payment(id)?.state !== "reclaimed") return;
30072
+ const intent = await intentRepository.get(id);
30073
+ if (!intent || intent.review.method !== "cashu" || ![
30074
+ "submitted",
30075
+ "unknown",
30076
+ "settled"
30077
+ ].includes(intent.review.state)) return;
30078
+ await intentRepository.put({
30079
+ ...intent,
30080
+ review: {
30081
+ ...intent.review,
30082
+ state: "failed",
30083
+ error: engineText("paymentTakenBack")
30084
+ }
30085
+ });
30086
+ this.emitState();
28200
30087
  }
28201
30088
  addService({ name, target }) {
28202
30089
  const cleanName = name.trim().slice(0, 48);
@@ -28332,9 +30219,11 @@ var GhostlyNode = class GhostlyNode {
28332
30219
  this.publicActivity.clear();
28333
30220
  }
28334
30221
  }
28335
- if (settings.avatar !== void 0 || settings.shareProfile !== void 0) for (const live of this.links.values()) live.link?.setAvatar(this.sharedAvatar);
30222
+ if (settings.avatar !== void 0 || settings.shareProfile !== void 0) {
30223
+ for (const live of this.links.values()) if (!this.quietEdge(live.stored)) live.link?.setAvatar(this.sharedAvatar);
30224
+ }
28336
30225
  if (settings.nick !== void 0 || settings.shareProfile !== void 0) {
28337
- for (const live of this.links.values()) live.link?.setNick(this.sharedNick);
30226
+ for (const live of this.links.values()) if (!this.quietEdge(live.stored)) live.link?.setNick(this.sharedNick);
28338
30227
  this.capsChanged();
28339
30228
  }
28340
30229
  if (settings.hyperdhtRelay !== void 0) {
@@ -28422,7 +30311,7 @@ var GhostlyNode = class GhostlyNode {
28422
30311
  const identity = identityFromSeedB64(invite.seedB64);
28423
30312
  if (Date.now() - (this.warmedKeys.get(identity.pubKeyZ32) ?? 0) < SPARE_INVITE_MAX_AGE_MS) return;
28424
30313
  this.warmedKeys.set(identity.pubKeyZ32, Date.now());
28425
- this.transport.publish(identity, emptyLinkRecords()).catch(() => {});
30314
+ this.warmKey(identity);
28426
30315
  } catch {}
28427
30316
  }
28428
30317
  /** What this peer advertises. Chat, voice and video are what Ghostly always offered. */
@@ -28460,7 +30349,9 @@ var GhostlyNode = class GhostlyNode {
28460
30349
  const live = this.links.get(linkId);
28461
30350
  if (!live?.stored.group) return;
28462
30351
  this.links.delete(linkId);
30352
+ this.edgeRtcOff.delete(linkId);
28463
30353
  this.groupWakeReceived.delete(linkId);
30354
+ this.quietEdges.delete(linkId);
28464
30355
  this.groupNativeWaiting.delete(linkId);
28465
30356
  this.groupNativeRetryAt = 0;
28466
30357
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
@@ -28582,11 +30473,13 @@ var GhostlyNode = class GhostlyNode {
28582
30473
  const { stored } = live;
28583
30474
  const group = stored.group, peer = stored.groupPeer, entry = !!stored.groupEntry;
28584
30475
  const role = stored.groupEntry ?? "edge";
30476
+ const member = () => entry || this.groups.isCommunityGroup(group) || this.groups.inRoster(group, peer);
28585
30477
  let seen = false;
28586
30478
  const native = this.keepsGroupNative(stored);
28587
30479
  const resumeOn = native ? TRANSPORTS.find((t) => t !== "webrtc/1" && t in this.nativeFactories && !!stored.peerTransports?.includes(t)) : "webrtc/1";
28588
30480
  live.pairing = { status: "connecting" };
28589
30481
  live.link = new GhostLink({
30482
+ ownRecords: true,
28590
30483
  paymentMethods: entry ? {
28591
30484
  cashu: false,
28592
30485
  lightning: false,
@@ -28601,7 +30494,7 @@ var GhostlyNode = class GhostlyNode {
28601
30494
  usdtPaymentsSupport: !entry,
28602
30495
  barkPaymentsSupport: !entry,
28603
30496
  params: stored,
28604
- rtcAvailable: typeof RTCPeerConnection !== "undefined",
30497
+ rtcAvailable: typeof RTCPeerConnection !== "undefined" && !this.edgeRtcOff.has(linkId),
28605
30498
  resume: !entry && stored.edgeLive ? resumeOn : void 0,
28606
30499
  native: {
28607
30500
  peerDescriptors: stored.peerDescriptors,
@@ -28624,7 +30517,7 @@ var GhostlyNode = class GhostlyNode {
28624
30517
  trustOnFirstUse: false
28625
30518
  },
28626
30519
  transport: this.groupTransport,
28627
- nick: this.sharedNick,
30520
+ nick: !entry && this.quietEdge(stored) ? void 0 : this.sharedNick,
28628
30521
  pollIntervals: entry || this.groups.isCommunityGroup(group) ? this.pollIntervals : meshEdgeIntervals(this.pollIntervals, () => this.groups.meshSize(group)),
28629
30522
  autoConnect: true,
28630
30523
  ...entry ? {
@@ -28640,12 +30533,13 @@ var GhostlyNode = class GhostlyNode {
28640
30533
  getHostedHttpService: () => void 0,
28641
30534
  groupsSupport: true,
28642
30535
  events: {
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),
30536
+ onGroupFrame: (frame) => entry ? this.groups.handleContactFrame(linkId, frame) : !member() ? this.groups.handleEdgeFrame(group, peer, 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),
28644
30537
  onGroupsSupport: (supported) => {
28645
30538
  if (supported) traceJoin(group, "link.ready", { role });
28646
30539
  if (!entry) this.noteEdgeLive(linkId, supported);
28647
30540
  if (supported) {
28648
30541
  if (entry) this.groups.entryReady(group, linkId, peer);
30542
+ else if (!member()) this.groups.edgeReady(group, peer, linkId);
28649
30543
  else {
28650
30544
  this.groups.edgeReady(group, peer, linkId);
28651
30545
  this.groupPayments.edgeReady(group, linkId).catch(() => {});
@@ -28670,10 +30564,10 @@ var GhostlyNode = class GhostlyNode {
28670
30564
  if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
28671
30565
  },
28672
30566
  ...entry ? {} : {
28673
- onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
28674
- onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
28675
- onPayment: (payment) => this.desk.onPayment(linkId, payment),
28676
- onPaymentResult: (result) => this.desk.onPaymentResult(linkId, result),
30567
+ onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
30568
+ onPaymentAsk: (ask) => member() ? this.desk.onPaymentAsk(linkId, ask) : void 0,
30569
+ onPayment: (payment) => member() ? this.desk.onPayment(linkId, payment) : void 0,
30570
+ onPaymentResult: (result) => member() ? this.desk.onPaymentResult(linkId, result) : void 0,
28677
30571
  onPeerNick: (nick) => this.groups.edgeNick(group, peer, nick ?? void 0)
28678
30572
  },
28679
30573
  onPresence: (presence) => {
@@ -28693,10 +30587,15 @@ var GhostlyNode = class GhostlyNode {
28693
30587
  live.pairing = state;
28694
30588
  this.emitState();
28695
30589
  },
30590
+ onDirectEvidence: (evidence) => {
30591
+ this.directPath.note(peer, evidence);
30592
+ if (!entry && evidence !== "open" && evidence !== "closed") this.edgeWithoutRtc(linkId);
30593
+ },
28696
30594
  onDataLinkState: (state) => {
28697
30595
  traceJoin(group, `link.${state}`, { role });
28698
30596
  if (state === "open" || live.dataLink === "open") live.lastSyncAt = Date.now();
28699
- if (state === "open" && !entry) this.desk.replay(linkId).catch(() => {});
30597
+ if (state === "open" && !entry) this.groups.edgeOpen(group, peer);
30598
+ if (state === "open" && !entry && member()) this.desk.replay(linkId).catch(() => {});
28700
30599
  live.dataLink = state;
28701
30600
  if (entry && state !== "open" && live.entryDone) this.closeGroupLink(linkId).catch(() => {});
28702
30601
  if (state !== "open" && live.pairing?.status !== "error") live.pairing = { status: "connecting" };
@@ -28723,7 +30622,27 @@ var GhostlyNode = class GhostlyNode {
28723
30622
  */
28724
30623
  keepsGroupNative(stored) {
28725
30624
  if (this.options.webrtcGroupLinks) return false;
28726
- return typeof RTCPeerConnection === "undefined" || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
30625
+ return typeof RTCPeerConnection === "undefined" || this.edgeRtcOff.has(stored.id) || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
30626
+ }
30627
+ /**
30628
+ * An edge's WebRTC attempt exchanged both descriptions and connected nothing, or found no candidate at all
30629
+ * (`DirectEvidence`): a network that lets no direct connection through (a VPN, a firewall, on either side). WebRTC is
30630
+ * all an edge between two apps that have it runs, and there is no TURN server unless someone set one, so the edge
30631
+ * never went live, and the member stayed unreachable for as long as that network lasted. The edge starts again as on an
30632
+ * app with no WebRTC (WISP 9xx § Transports): it runs its native endpoints and says so in its packet (`_tr`, with no
30633
+ * `webrtc/1`), the member's app starts its own for it as it does for a Linux Desktop, and the two meet over Iroh
30634
+ * through its relay. For this run of the app only.
30635
+ */
30636
+ async edgeWithoutRtc(linkId) {
30637
+ const live = this.links.get(linkId), link = live?.link;
30638
+ if (!live || !link || !live.stored.group || live.stored.groupEntry || this.shuttingDown || this.options.webrtcGroupLinks) return;
30639
+ if (this.edgeRtcOff.has(linkId) || this.keepsGroupNative(live.stored) || !Object.keys(this.nativeFactories).length) return;
30640
+ this.edgeRtcOff.add(linkId);
30641
+ traceLink(live.myPubKeyZ32, "edge-rtc-off", {});
30642
+ live.link = null;
30643
+ await link.stop(false).catch(() => {});
30644
+ if (this.links.get(linkId) !== live || live.link || this.shuttingDown || this.settings.online === false) return;
30645
+ this.startEdge(linkId);
28727
30646
  }
28728
30647
  startLink(linkId, messages) {
28729
30648
  const live = this.links.get(linkId);
@@ -28731,7 +30650,7 @@ var GhostlyNode = class GhostlyNode {
28731
30650
  const { stored } = live;
28732
30651
  if (stored.group) return this.startEdge(linkId);
28733
30652
  if (stored.profile) live.pairing = { status: "connecting" };
28734
- const lastSeenTimestamp = messages.reduce((max, m) => m.sender === "peer" && m.via === "pkarr" ? Math.max(max, m.timestamp) : max, 0);
30653
+ const lastSeenTimestamp = messages.reduce((max, m) => m.sender === "peer" && m.via === "pkarr" ? Math.max(max, m.sentAt ?? m.timestamp) : max, 0);
28735
30654
  const credentials = stored.profile && stored.participationSeed ? {
28736
30655
  seedB64: stored.participationSeed,
28737
30656
  peerKey: stored.pairedPeerKey,
@@ -28964,6 +30883,11 @@ var GhostlyNode = class GhostlyNode {
28964
30883
  live.peerAck = ack;
28965
30884
  this.emitState();
28966
30885
  },
30886
+ onDirectEvidence: (evidence) => this.directPath.note(stored.peerPubKeyZ32, evidence),
30887
+ onPeerClock: (packetAt, readBefore, readAt) => {
30888
+ const peer = live.stored.pairedPeerKey;
30889
+ if (peer && !live.stored.group) this.clock.peer(peer, packetAt, readBefore, readAt);
30890
+ },
28967
30891
  onDataLinkState: (state) => {
28968
30892
  const was = live.dataLink;
28969
30893
  live.dataLink = state;
@@ -29023,6 +30947,7 @@ var GhostlyNode = class GhostlyNode {
29023
30947
  };
29024
30948
  db.patchLink(linkId, { inviteCode: void 0 });
29025
30949
  }
30950
+ if (message.via !== "datalink") this.chatInUse(live);
29026
30951
  return this.storeMessage({
29027
30952
  linkId,
29028
30953
  id: `peer_${message.id ?? message.timestamp}`,
@@ -29100,7 +31025,8 @@ var GhostlyNode = class GhostlyNode {
29100
31025
  };
29101
31026
  },
29102
31027
  changed: (record) => this.peerCapsChanged(linkId, record),
29103
- published: () => live.link?.announceCapsRevision()
31028
+ published: () => live.link?.announceCapsRevision(),
31029
+ refused: (error) => traceLink(live.myPubKeyZ32, "caps-refused", { reason: error.reason })
29104
31030
  });
29105
31031
  if (stored.pairedPeerKey && nativeUp) {
29106
31032
  const late = setTimeout(() => caps.start(), STARTUP_QUIET_MS);
@@ -29191,7 +31117,7 @@ var GhostlyNode = class GhostlyNode {
29191
31117
  get nativeFactories() {
29192
31118
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
29193
31119
  return {
29194
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-_rJsllcP.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
31120
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-VM9STKLd.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
29195
31121
  ...this.options.nativeTransports,
29196
31122
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
29197
31123
  };
@@ -29254,70 +31180,110 @@ var GhostlyNode = class GhostlyNode {
29254
31180
  }
29255
31181
  this.emitState();
29256
31182
  }
29257
- ensureNativeEndpoints(linkId) {
31183
+ /**
31184
+ * A text going or coming in a 1:1 chat with no live session: the chat is in use, and takes the native listeners it
31185
+ * lacks as the chat on screen would (a bot answering its ninth contact has no chat on screen).
31186
+ */
31187
+ chatInUse(live) {
31188
+ const link = live.link;
31189
+ if (live.stored.group || !link || link.isDataLinkOpen || !this.keepsNativeEndpoints(live.stored.id, live.stored)) return;
31190
+ if (Object.keys(this.nativeFactories).every((t) => link.availableTransports.includes(t))) return;
31191
+ this.ensureNativeEndpoints(live.stored.id, true);
31192
+ }
31193
+ /**
31194
+ * Whether the chat goes live only over a native transport: this app has no WebRTC, or its contact has none (its record
31195
+ * lists no `webrtc/1`). Not known yet (no record read) counts as only natively, as a chat before its first record must.
31196
+ */
31197
+ reachedOnlyNatively(live) {
31198
+ const theirs = live.stored.capsState?.peer?.transports ?? live.stored.peerTransports;
31199
+ return typeof RTCPeerConnection === "undefined" || !theirs || !theirs.includes("webrtc/1");
31200
+ }
31201
+ /** `inUse`: a text is going or coming in the chat now, which counts as the chat on screen does for taking a listener. */
31202
+ ensureNativeEndpoints(linkId, inUse = false) {
29258
31203
  const expected = this.links.get(linkId)?.link;
29259
- const operation = this.nativeQueue.then(async () => {
31204
+ const starts = Object.entries(this.nativeFactories).map(([transport, factory]) => {
31205
+ const key = transport;
31206
+ const operation = (this.nativeQueues.get(key) ?? Promise.resolve()).then(() => factory ? this.startNativeEndpoint(linkId, expected, key, factory, inUse) : false);
31207
+ this.nativeQueues.set(key, operation.then(() => {}, () => {}));
31208
+ return operation;
31209
+ });
31210
+ return Promise.all(starts).then((full) => {
29260
31211
  const live = this.links.get(linkId), link = live?.link;
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;
29265
- for (const [transport, factory] of Object.entries(this.nativeFactories)) {
29266
- const key = transport;
29267
- if (!factory || link.availableTransports.includes(key)) continue;
29268
- live.transportErrors ??= {};
29269
- try {
29270
- const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
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
- }
29285
- await victim.link.releaseEndpoint(key);
29286
- victim.transportErrors ??= {};
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);
29289
- }
29290
- if (this.shuttingDown || live.link !== link) return;
29291
- const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
29292
- const transportSeeds = {
29293
- ...live.stored.transportSeeds,
29294
- [key]: seed
29295
- };
29296
- await db.patchLink(linkId, { transportSeeds });
29297
- live.stored = {
29298
- ...live.stored,
29299
- transportSeeds
31212
+ if (this.shuttingDown || !live || !link || link !== expected) return;
31213
+ if (live.stored.group && this.keepsGroupNative(live.stored)) {
31214
+ if (full.some(Boolean) && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
31215
+ else this.groupNativeWaiting.delete(linkId);
31216
+ }
31217
+ this.emitState();
31218
+ });
31219
+ }
31220
+ /** One transport's listener for one chat, in that transport's queue. True when a group's link found no slot free. */
31221
+ async startNativeEndpoint(linkId, expected, key, factory, inUse) {
31222
+ const live = this.links.get(linkId), link = live?.link;
31223
+ if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
31224
+ const group = !!live.stored.group;
31225
+ if (group && !this.keepsGroupNative(live.stored)) return false;
31226
+ if (link.availableTransports.includes(key)) return false;
31227
+ live.transportErrors ??= {};
31228
+ try {
31229
+ const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
31230
+ if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return true;
31231
+ if (owners.length >= NATIVE_SLOTS) {
31232
+ const now = Date.now();
31233
+ const taking = !group && (this.activeLinkId === linkId || inUse);
31234
+ 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] ?? (taking ? owners.filter((other) => other !== live && other.stored.id !== this.activeLinkId && other.link?.canReleaseEndpoint(key) && now - (other.nativeTakenAt?.[key] ?? 0) >= 12e4).sort((a, b) => a.lastMessageAt - b.lastMessageAt)[0] : void 0);
31235
+ const idle = victim || !taking || !this.reachedOnlyNatively(live) ? void 0 : owners.filter((other) => other !== live && !other.stored.group && other.stored.id !== this.activeLinkId && !this.fileDesk.moving(other.stored.id) && other.link?.canYieldEndpoint(key, 12e4)).sort((a, b) => a.link.lastActivityAt - b.link.lastActivityAt)[0];
31236
+ const idleFor = idle ? now - idle.link.lastActivityAt : 0;
31237
+ if (idle && await idle.link.yieldEndpoint(key, 12e4)) {
31238
+ traceLink(live.myPubKeyZ32, "native-take", {
31239
+ transport: key,
31240
+ idle: idleFor,
31241
+ active: this.activeLinkId === linkId
31242
+ });
31243
+ live.nativeTakenAt = {
31244
+ ...live.nativeTakenAt,
31245
+ [key]: Date.now()
29300
31246
  };
29301
- const endpoint = await factory(seed);
29302
- if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
29303
- await endpoint.close();
29304
- return;
29305
- }
29306
- link.registerEndpoint(endpoint);
29307
- delete live.transportErrors[key];
29308
- this.capsChanged(linkId);
29309
- } catch (error) {
29310
- live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
31247
+ idle.transportErrors ??= {};
31248
+ idle.transportErrors[key] = "Listener given to a chat in use: this one was quiet. Open this chat to take one back; your messages and transport identity are saved.";
31249
+ } else if (!victim) {
31250
+ traceLink(live.myPubKeyZ32, "native-no-slot", {
31251
+ transport: key,
31252
+ active: this.activeLinkId === linkId
31253
+ });
31254
+ if (this.activeLinkId === linkId && !group) this.retryActiveSlot(linkId);
31255
+ throw new Error("All eight native connection slots are in use. This chat takes one once a chat live over one has been quiet for 2 minutes. Disconnect a native connection in another chat to free one now.");
31256
+ } else {
31257
+ await victim.link.releaseEndpoint(key);
31258
+ victim.transportErrors ??= {};
31259
+ victim.transportErrors[key] = "Listener released for another chat. Open this chat to restore it; your messages and transport identity are saved.";
31260
+ if (victim.stored.group) this.groupNativeWaiting.add(victim.stored.id);
29311
31261
  }
29312
31262
  }
29313
- if (group) {
29314
- if (full && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
29315
- else this.groupNativeWaiting.delete(linkId);
31263
+ if (this.shuttingDown || live.link !== link) return false;
31264
+ const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
31265
+ const transportSeeds = {
31266
+ ...live.stored.transportSeeds,
31267
+ [key]: seed
31268
+ };
31269
+ live.stored = {
31270
+ ...live.stored,
31271
+ transportSeeds
31272
+ };
31273
+ await db.patchLink(linkId, { transportSeeds });
31274
+ const endpoint = await factory(seed);
31275
+ if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
31276
+ await endpoint.close();
31277
+ return false;
29316
31278
  }
31279
+ link.registerEndpoint(endpoint);
31280
+ delete live.transportErrors[key];
31281
+ this.capsChanged(linkId);
29317
31282
  this.emitState();
29318
- });
29319
- this.nativeQueue = operation.catch(() => {});
29320
- return operation;
31283
+ } catch (error) {
31284
+ live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
31285
+ }
31286
+ return false;
29321
31287
  }
29322
31288
  /**
29323
31289
  * The order saved chats start in, which is the order they take native listeners in (`NATIVE_SLOTS` per transport):
@@ -29446,15 +31412,54 @@ var GhostlyNode = class GhostlyNode {
29446
31412
  deleted: unique.filter((_, i) => !rows[i])
29447
31413
  });
29448
31414
  }
31415
+ /**
31416
+ * At the start, before anything reads a history: received rows an earlier version stored under a time that has not
31417
+ * come yet take a place before now (`settleAhead`). One row is read when nothing is ahead, which is every start but
31418
+ * the first after the update.
31419
+ */
31420
+ async settleHistory(linkId) {
31421
+ const now = Date.now();
31422
+ const newest = (await db.getMessagePage(linkId, { limit: 1 })).messages[0];
31423
+ if (!newest || newest.timestamp <= now) return;
31424
+ for (const row of settleAhead(await db.getMessages(linkId), now)) await db.putMessage(row);
31425
+ }
31426
+ /**
31427
+ * A new row with its place in its history. A received row takes it here and now, past the last place given in its
31428
+ * chat: the time its sender says is kept beside it (`sentAt`) and shown, and orders nothing (WISP 400, requirement
31429
+ * 10). A sender's clock ahead or behind then moves no message past another, and what I send next goes below what I
31430
+ * just received. A row of mine keeps its own time; one placed already (it has `sentAt`) is left as it is. A line
31431
+ * of a group's history (a membership change, a rename) is not a message: it stays at its commit's time, but never
31432
+ * ahead of this clock nor above a row placed here since the app started. The admin's clock picks that time: two
31433
+ * minutes ahead, "X joined" sat under everything said in the next two minutes; behind, it went above what the
31434
+ * members had just read. Read late with nothing newer here (a member back after a while), it keeps its moment.
31435
+ */
31436
+ placed(message) {
31437
+ const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
31438
+ if (message.event) {
31439
+ if (message.sender !== "peer") return message;
31440
+ const timestamp = Math.max(Math.min(receivedTimestamp(message.timestamp, now), now), last + 1);
31441
+ this.placedAt.set(message.linkId, timestamp);
31442
+ return {
31443
+ ...message,
31444
+ timestamp
31445
+ };
31446
+ }
31447
+ if (message.sender === "peer" && message.sentAt === void 0) {
31448
+ const sentAt = claimedTime(message.timestamp);
31449
+ message = {
31450
+ ...message,
31451
+ timestamp: arrivalKey(now, last),
31452
+ ...sentAt !== void 0 && { sentAt }
31453
+ };
31454
+ this.placedAt.set(message.linkId, message.timestamp);
31455
+ } else this.placedAt.set(message.linkId, Math.max(last, Math.min(message.timestamp, now)));
31456
+ return message;
31457
+ }
29449
31458
  async storeMessage(message) {
29450
31459
  await this.storeNewMessage(message);
29451
31460
  }
29452
31461
  /** Stores a message; false when it was there already (or deleted here): nothing new came. */
29453
31462
  async storeNewMessage(message) {
29454
- if (message.sender === "peer") message = {
29455
- ...message,
29456
- timestamp: receivedTimestamp(message.timestamp)
29457
- };
29458
31463
  const edge = this.links.get(message.linkId)?.stored;
29459
31464
  if (edge?.group && edge.groupPeer && !edge.groupEntry) message = {
29460
31465
  ...message,
@@ -29469,6 +31474,7 @@ var GhostlyNode = class GhostlyNode {
29469
31474
  id: `${message.linkId}:${message.id}`,
29470
31475
  ...message.sender === "peer" ? { member: pay.member } : {}
29471
31476
  };
31477
+ message = this.placed(message);
29472
31478
  const live = this.links.get(message.linkId);
29473
31479
  if (live?.stored.deletedIds?.includes(message.id)) return false;
29474
31480
  message = await this.resolveReply(message);
@@ -29497,8 +31503,9 @@ var GhostlyNode = class GhostlyNode {
29497
31503
  };
29498
31504
  }
29499
31505
  if (!await db.addMessage(message)) return false;
31506
+ this.clockSample(message);
29500
31507
  const newest = this.newestAt.get(message.linkId);
29501
- if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, message.timestamp));
31508
+ if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(message.linkId))));
29502
31509
  if (message.sender === "peer") this.messageFeedback("message", message, newest);
29503
31510
  if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
29504
31511
  const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
@@ -29583,7 +31590,7 @@ var GhostlyNode = class GhostlyNode {
29583
31590
  status: live.status,
29584
31591
  dataLink: live.dataLink,
29585
31592
  peerOnline: presence.online,
29586
- peerLastSeenAt: presence.lastPacketAt,
31593
+ peerLastSeenAt: presenceSeenAt(presence),
29587
31594
  peerServices: presence.services,
29588
31595
  lastMessageAt: live.lastMessageAt,
29589
31596
  peerAck: live.peerAck,
@@ -29672,6 +31679,7 @@ var EngineServer = class {
29672
31679
  }
29673
31680
  }, options);
29674
31681
  this.ready = this.node.start();
31682
+ this.ready.catch(() => {});
29675
31683
  }
29676
31684
  attach(client) {
29677
31685
  this.clients.add(client);
@@ -29697,6 +31705,11 @@ var EngineServer = class {
29697
31705
  });
29698
31706
  this.histories.get(client)?.add(link.id);
29699
31707
  });
31708
+ }, (error) => {
31709
+ this.post(client, {
31710
+ kind: "start-failed",
31711
+ failure: profileOpenFailure(error)
31712
+ });
29700
31713
  }).catch(() => {
29701
31714
  this.clients.delete(client);
29702
31715
  });
@@ -29804,4 +31817,4 @@ function signalKind(signal) {
29804
31817
  //#endregion
29805
31818
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
29806
31819
 
29807
- //# sourceMappingURL=server-C-8wbsAX.js.map
31820
+ //# sourceMappingURL=server-BnNXYzR9.js.map