@ghostlytools/cli 1.0.2 → 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 +2 -2
  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-cMlRWrmD.js → hyperdhtRelay-VM9STKLd.js} +3 -3
  13. package/dist/assets/{hyperdhtRelay-cMlRWrmD.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-_roUnpDA.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-Cajp8VLY.js → server-BnNXYzR9.js} +1756 -202
  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 +905 -23623
  25. package/dist/ghostly.mjs.map +1 -1
  26. package/package.json +1 -1
  27. package/dist/assets/fileBytesOpfs-By55XVTI.js.map +0 -1
  28. package/dist/assets/mainline-_roUnpDA.js.map +0 -1
  29. package/dist/assets/server-Cajp8VLY.js.map +0 -1
@@ -1,4 +1,7 @@
1
- import { $ as boundedIdentityFetch, $i as GROUP_MEMBER_CAP, $n as LIVENESS_PING_MS, $r as IDENTITY_REVOCATION_LABEL, $t as GroupWakeLimiter, A as TEST_MINT, Aa as normalizeRelayUrl, Ai as GROUP_REACTION_FRAME, An as assertTokenUnits, Ar as nextPinNumber, At as transact, B as registeredLightningProviders, Ba as identityFromSeedB64, Bi as pairedReplyAuthor, Bn as isPubkyKey, Br as vapidKeysMatch, Bt as communityHasFrame, C as networkLabel, Ca as createChatInvite, Ci as EDIT_RATE_WINDOW_MS, Cn as ONCHAIN_PROVIDER, Cr as transferEnded, Ct as STORES, D as WALLET_TYPES, Da as DEFAULT_RELAYS, Di as forwardedAgain, Dn as TEST_USDT_FAUCET_AMOUNT, Dt as fileStore, Ea as inviteOwnership, Ei as parseLinkPreview, En as SEPOLIA_TEST_USDT_FAUCET, Er as typingActivity, Et as deleteDatabase, F as paysItsOwnInvoices, Fi as reactionEmoji, Fn as SPARK_NETWORKS, Fr as WakeLimiter, Ft as openGroupTyping, G as SourceConfigError, Ga as randomBytes, Gi as checkStatusCard, Gn as cashuRequestPayload, Gr as HoldKeys, Gt as decodeGroupEntryLink, H as reserveAdapterIds, Ha as sanitizeAvatar, Hi as replySnippet, Hn as engineText, Hr as EDIT_CAPABILITY, Ht as decodeCommunityLink, I as BACKUP_MEDIA_TYPE, Ii as reactionIsNewer, In as SPARK_PROVIDER, Ir as newWakeToken, It as sealGroupTyping, J as networkMode, Ja as utf8Decode, Ji as arrivalKey, Jn as isFederationId, Jr as readManifest, Jt as knockIdentity, K as SourceUnreachableError, Ka as toBase64Url, Ki as sameButtonText, Kn as fedimintRequestPayload, Kr as HoldRefusedError, Kt as encodeGroupEntryLink, L as MAX_BACKUP_BYTES, Li as readReaction, Ln as sparkAddressKind, Lr as relayRequest, Lt as COMMUNITY_LIMITS, M as isTestMint, Ma as isDiscoveryBudgetError, Mi as ReactionWindow, Mn as formatPaymentAmount, Mr as pinNumberHolds, N as isWorthlessMint, Na as withRequestOptions, Ni as nextReactionNumber, Nn as validatePaymentTarget, Nr as readPin, Nt as GroupTypingBoard, O as BITCOIN_INVOICE_ON_TESTNET, Oa as RelayTransport, Oi as readForwarded, On as USDT_PUBLIC_RPC, Or as PIN_LIMITS, Ot as openDb, P as mintNetwork, Pa as PacketTooLargeError, Pi as queueReaction, Pn as walletNetworkOf, Pr as WAKE_CALL_INTERVAL_MS, Pt as groupTypingEpoch, Q as setOwnDidSource, Qi as shownTime, Qn as INVITE_TAKEN, Qr as dialDescriptors, R as identityProviders, Ra as createIdentity, Ri as validReactionNumber, Rn as sparkInvoiceDetails, Rr as wakeRequest, Rt as CommunitySession, S as WrongNetworkError, Sa as OwnInviteError, Si as EDIT_BUFFER_MS, Sn as EVM_TEST_CHAINS, Sr as FILE_LIMITS, T as cashuMint, Ta as encodeInviteCode, Ti as dhtEditId, Tn as SEPOLIA_TEST_USDT, Tr as TypingSender, Tt as databaseName, U as NothingSpentError, Ua as bytesEqual, Ui as BUTTON_ID, Un as decodeBolt11, Ur as HOLD_CAPABILITY, Ut as encodeCommunityLink, V as registeredOnchainProviders, Va as publicKeyFromZ32, Vi as readReply, Vn as engineError, Vt as communityMessageId, W as PROVIDER_PLATFORMS, Wa as fromBase64Url, Wi as STATUS_CARD_LIMITS, Wn as ENDPOINT, Wr as HOLD_LIMITS, X as assertPublicServer, Xa as __exportAll, Xi as heardTime, Xn as parseFedimintRequestPayload, Xr as DHT_TEXT_CAPABILITY, Xt as mergeKnocks, Y as redact, Ya as utf8Encode, Yi as claimedTime, Yn as parseCashuRequestPayload, Yr as CapsExchange, Yt as knockRecords, Z as resolveAtprotoDid, Zi as receivedTimestamp, Zn as GhostLink, Zr as capsDescriptors, Zt as readKnocks, _ as newDeviceKey, _a as parseCallSignal, _i as fileMessageText, _n as didDhtFromPublicKey, _r as readBeacon, _t as fileBytes, a as createFailure, aa as DirectPathWatch, ai as receivedIdentityStatus, an as GroupSession, ar as COMMUNITY_TOPOLOGY, at as replyRef, b as ModeChanged, ba as LEGACY_SERVICES, bi as DHT_MESSAGE_TTL, bn as signDidDhtPacket, br as shouldBeHub, bt as removeFileBytes, c as paymentNetwork, ca as traceLink, ci as PeerProofs, cr as doorHubs, ct as paymentWireId, d as networkReason, da as parseLocalTarget, di as TRANSPORTS, dn as parseCommunityEdit, dr as lobbyRecords, dt as removeStored, ei as IdentityExchange, en as groupWakeFrame, er as pairedMessageFrame, et as verifyIdentity, f as rpcHost, fa as webLocalFetch, fi as automaticTransport, fn as mentionsMember, fr as mergeBeacon, ft as storedSize, g as intentRepository, ga as RTC_CONFIG, gi as safeBlobType, gn as didDhtDocument, gr as rankHubs, h as ProviderSources, ha as encodeControl, hi as parseImageMeta, hn as normalizeAtprotoHandle, hr as pickHubs, ht as blobDigest, i as assertConfirmedReal, ia as edgeParams, ii as newIdentityBinding, in as GROUP_READ_NOTE, ir as pickMeshHubs, it as receivedPairedReply, j as fakesLightning, ja as budgetRetryMs, ji as REACTION_LIMITS, jn as assertWholeSats, jr as pinIsNewer, jt as wrap, k as DEFAULT_MINTS, ka as currentRelays, ki as COMMUNITY_REACTION_FRAME, kn as WALLET_NETWORKS, kr as mayPin, kt as store, l as paymentNetworksOf, la as GhostlyHttpError, li as emptyProofLedger, ln as GROUP_EDIT_TEXT_BYTES, lr as freshHubs, lt as FileAppender, m as LightningCards, ma as decodeControl, mi as irohRelayUrl, mn as isAtprotoDid, mr as newerHead, n as loadFedimintSdk, na as rosterHas, ni as identityRevocationValue, nn as parseGroupWakeFrame, nr as mayBeHub, nt as groupReply, o as createTiming, oi as revokesIdentity, on as groupMessageId, or as beaconKeys, ot as replyTo, p as CASHU_CARD, pa as LIMITS, pi as irohDescriptor, pn as groupName, pr as mergeLobby, pt as streamStored, q as isNothingSpentError, qa as toZ32, qi as statusCardText, qn as findEndpoint, qr as newMailbox, qt as entryParams, r as WALLET_NAMES, ra as verifyCommitSignature, ri as identityStatement, rr as meshHubs, rt as pairedWireReply, s as crossNetwork, sa as RELAY_POLL_INTERVALS, si as lastSharedWithMe, sr as beaconRecords, st as paymentAlias, t as fedimintDatabase, ta as MEMBER_KEY, ti as emptyIdentityLedger, tn as groupWakes, tr as MESH_HUBS, tt as chosenResolver, u as walletInstances, ua as formatLocalTarget, ui as validExternalKey, un as carryMentions, ur as lobbyKeys, ut as readStored, v as sealSeed, va as emptyLinkRecords, vi as parseVoiceMeta, vn as encodeDidDhtDeactivated, vr as readBeaconHead, vt as fileBytesOf, w as CASHU_MINT_SOURCE, wa as decodeInviteCode, wi as RateWindow, wn as PaymentPreflightError, wr as transferStalled, wt as clearProfileStores, x as ModeGate, xa as serviceIdFromName, xi as peekDhtMailbox, xn as ETHEREUM_USDT, xr as ChatFiles, y as unsealSeed, ya as HTTP_SERVICE_PROTO, yi as parseVideoMeta, yn as encodeDidDhtPacket, yr as readLobby, z as onAdaptersChanged, za as identityFromSeed, zi as wireReaction, 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,
@@ -11531,6 +11676,7 @@ var FileDesk = class {
11531
11676
  if (chat) return chat;
11532
11677
  const made = {
11533
11678
  local: /* @__PURE__ */ new Map(),
11679
+ places: /* @__PURE__ */ new Map(),
11534
11680
  saving: Promise.resolve(),
11535
11681
  lastSaved: /* @__PURE__ */ new Map(),
11536
11682
  files: void 0
@@ -11570,7 +11716,13 @@ var FileDesk = class {
11570
11716
  this.deps.changed();
11571
11717
  }
11572
11718
  handle(linkId, frame) {
11573
- 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
+ });
11574
11726
  }
11575
11727
  /** Whether files/3 is live in this chat, and what the contact said it can take. */
11576
11728
  status(linkId) {
@@ -11798,6 +11950,8 @@ var FileDesk = class {
11798
11950
  ...file.video && { video: file.video },
11799
11951
  ...file.image && { image: file.image }
11800
11952
  };
11953
+ const place = chat.places.get(record.id), sentAt = claimedTime(file.timestamp);
11954
+ chat.places.delete(record.id);
11801
11955
  chat.saving = chat.saving.then(async () => {
11802
11956
  await fileStore.put({
11803
11957
  id,
@@ -11822,9 +11976,12 @@ var FileDesk = class {
11822
11976
  id: `peer_${record.id}`,
11823
11977
  text: fileMessageText(message),
11824
11978
  sender: "peer",
11825
- timestamp: file.timestamp,
11826
11979
  via: "datalink",
11827
11980
  file: message,
11981
+ ...place !== void 0 && sentAt !== void 0 ? {
11982
+ timestamp: place,
11983
+ sentAt
11984
+ } : { timestamp: file.timestamp },
11828
11985
  ...file.reply && { replyTo: receivedPairedReply(file.reply) },
11829
11986
  ...file.forwarded && { forwarded: file.forwarded },
11830
11987
  details: { wire: fileWire("files/3", file.size) }
@@ -12287,9 +12444,32 @@ var RESEND_POLICY = {
12287
12444
  /** Queued this long with the contact away, it is handed to store-and-forward when the chat allows it. */
12288
12445
  holdAfterMs: 6e4
12289
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;
12290
12457
  /** Durable message IDs survive retries. A lost receipt means unknown delivery, never a claim that the peer did
12291
12458
  * not receive the message: the receiver acknowledges a duplicate id without showing it twice, so an unconfirmed
12292
- * 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. */
12293
12473
  var Outbox = class {
12294
12474
  store;
12295
12475
  send;
@@ -12301,8 +12481,18 @@ var Outbox = class {
12301
12481
  waiting = /* @__PURE__ */ new Map();
12302
12482
  attempts = /* @__PURE__ */ new Map();
12303
12483
  busy = /* @__PURE__ */ new Set();
12304
- /** 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. */
12305
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;
12306
12496
  stopped = false;
12307
12497
  resender;
12308
12498
  policy;
@@ -12339,13 +12529,137 @@ var Outbox = class {
12339
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.");
12340
12530
  }
12341
12531
  }
12342
- /** Sends one message now. `manual`: the Retry button — a new window and a fresh count of attempts. */
12343
- 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) {
12344
12658
  if (this.stopped || this.busy.has(id) || this.receipts.has(id)) return;
12345
12659
  this.busy.add(id);
12346
12660
  let again;
12347
12661
  try {
12348
- let message = (await this.store.read()).find((m) => m.id === id);
12662
+ let message = read ?? (await this.store.read()).find((m) => m.id === id);
12349
12663
  if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
12350
12664
  const wait = this.waiting.get(id);
12351
12665
  if (wait?.timer) {
@@ -12372,6 +12686,7 @@ var Outbox = class {
12372
12686
  return;
12373
12687
  }
12374
12688
  this.track(id, typeof this.timeoutMs === "number" ? this.timeoutMs : this.timeoutMs(message));
12689
+ this.sentUnder.set(id, this.opened);
12375
12690
  const error = await this.send(message);
12376
12691
  if (error) {
12377
12692
  this.clear(id);
@@ -12412,25 +12727,53 @@ var Outbox = class {
12412
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
12413
12728
  * (a receipt on the link also ends the DHT's own retries).
12414
12729
  */
12415
- async flush({ reopened = false } = {}) {
12416
- if (this.stopped || !reopened && !this.waiting.size) return;
12417
- if (reopened) for (const id of this.busy) this.reopenedWhileBusy.add(id);
12418
- 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);
12419
- for (const message of due) await this.again(message);
12420
- }
12421
- /** A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry it. */
12422
- async again(message) {
12423
- if (message.sender !== "me" || message.via === "hold") return;
12424
- if (message.delivery === "queued" || message.delivery === "waiting") {
12425
- 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);
12426
12745
  return;
12427
12746
  }
12428
- 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;
12429
12757
  this.clear(message.id);
12430
- if (this.resender.ready(message)) await this.transmit(message.id);
12431
- 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 () => {});
12432
12775
  }
12433
- async disconnected() {
12776
+ async requeueUnconfirmed() {
12434
12777
  const messages = await this.store.read();
12435
12778
  for (const id of [...this.receipts.keys()]) {
12436
12779
  if (messages.find((m) => m.id === id)?.via === "pkarr") {
@@ -12446,33 +12789,40 @@ var Outbox = class {
12446
12789
  this.reopenedWhileBusy.clear();
12447
12790
  for (const wait of this.waiting.values()) if (wait.timer) clearTimeout(wait.timer);
12448
12791
  this.waiting.clear();
12449
- await this.disconnected();
12792
+ this.dropped = false;
12793
+ await this.requeueUnconfirmed();
12450
12794
  }
12451
- /** Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more. */
12452
- async attempt(id) {
12453
- 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;
12454
12802
  const message = (await this.store.read()).find((m) => m.id === id);
12455
12803
  if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
12456
12804
  this.forget(id);
12457
- return;
12805
+ return true;
12458
12806
  }
12459
12807
  if ((message.resendUntil ?? Infinity) <= this.now()) {
12460
12808
  if (message.delivery === "waiting") {
12461
12809
  this.forget(id);
12462
12810
  await this.store.update(id, "failed", `Your contact was not reachable within ${days(this.policy.windowMs)}. It was not sent; Retry sends it.`);
12463
12811
  } else await this.fail(id, `Not confirmed within ${days(this.policy.windowMs)}.`);
12464
- return;
12812
+ return true;
12465
12813
  }
12466
12814
  if (this.resender.ready(message)) {
12467
- await this.transmit(id);
12468
- return;
12815
+ if (passed) await this.catchUp(passed);
12816
+ await this.transmitNow(id);
12817
+ return true;
12469
12818
  }
12470
12819
  const wait = this.waiting.get(id);
12471
12820
  if (this.resender.divert && wait && this.now() - wait.since >= this.policy.holdAfterMs && await this.resender.divert(message)) {
12472
12821
  this.forget(id);
12473
- return;
12822
+ return true;
12474
12823
  }
12475
12824
  if (!wait?.timer) this.schedule(id);
12825
+ return false;
12476
12826
  }
12477
12827
  /** After a failed attempt: queued again, unless its window or its attempts are spent. */
12478
12828
  async requeue(id, reason) {
@@ -12507,7 +12857,7 @@ var Outbox = class {
12507
12857
  wait.timer = setTimeout(() => {
12508
12858
  wait.timer = void 0;
12509
12859
  wait.step++;
12510
- this.attempt(id).catch(() => {});
12860
+ this.turn(() => this.check(id)).catch(() => {});
12511
12861
  }, delay);
12512
12862
  this.waiting.set(id, wait);
12513
12863
  }
@@ -12516,17 +12866,20 @@ var Outbox = class {
12516
12866
  if (wait?.timer) clearTimeout(wait.timer);
12517
12867
  this.waiting.delete(id);
12518
12868
  this.attempts.delete(id);
12869
+ this.sentUnder.delete(id);
12519
12870
  }
12520
12871
  track(id, duration) {
12521
12872
  this.clear(id);
12522
- this.receipts.set(id, setTimeout(() => {
12523
- this.receipts.delete(id);
12524
- (async () => {
12873
+ const timer = setTimeout(() => {
12874
+ this.turn(async () => {
12875
+ if (this.receipts.get(id) !== timer) return;
12876
+ this.receipts.delete(id);
12525
12877
  const message = (await this.store.read()).find((m) => m.id === id);
12526
12878
  if (message?.via === "pkarr") await this.expired(message);
12527
12879
  else await this.requeue(id, "No receipt received yet.");
12528
- })().catch(() => {});
12529
- }, duration));
12880
+ }).catch(() => {});
12881
+ }, duration);
12882
+ this.receipts.set(id, timer);
12530
12883
  }
12531
12884
  clear(id) {
12532
12885
  const timer = this.receipts.get(id);
@@ -12764,7 +13117,8 @@ var ITEM = {
12764
13117
  invoice: (a) => `An invoice for ${a}, not paid yet`,
12765
13118
  paid: (a) => `${a} paid to an invoice, not claimed from the mint yet`,
12766
13119
  unclaimed: (a) => `${a} the mint says it issued for an invoice, never received here`,
12767
- 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`
12768
13122
  };
12769
13123
  var ENGLISH_REMOVAL = {
12770
13124
  sats: (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`,
@@ -12789,8 +13143,13 @@ function items(type, network, view, words, card) {
12789
13143
  ...a.paymentId ? { paymentId: a.paymentId } : {}
12790
13144
  });
12791
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
+ }] : [];
12792
13151
  return {
12793
- awaiting: mine.filter((a) => !returns(a)).map(item),
13152
+ awaiting: [...mine.filter((a) => !returns(a)).map(item), ...swaps],
12794
13153
  returnable: mine.filter(returns).map(item)
12795
13154
  };
12796
13155
  }
@@ -12812,7 +13171,7 @@ function walletRemoval(type, network, view, intents = [], card, words = ENGLISH_
12812
13171
  case "cashu": return {
12813
13172
  ...base,
12814
13173
  custody: "device",
12815
- held: known(view?.balance ?? 0),
13174
+ held: known((view?.balance ?? 0) + (view?.setAside ?? 0)),
12816
13175
  backup: "tokens",
12817
13176
  pending
12818
13177
  };
@@ -13287,6 +13646,16 @@ var db = {
13287
13646
  want += newest.length - page.length;
13288
13647
  }
13289
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
+ },
13290
13659
  /** Returns false when the message was already stored. */
13291
13660
  async addMessage(message) {
13292
13661
  const tx = (await openDb()).transaction(STORES.messages, "readwrite");
@@ -13771,6 +14140,16 @@ var FAREWELL_SAY_MS = 1e4;
13771
14140
  * a returning member whose clock runs behind the admin's is told all the same.
13772
14141
  */
13773
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;
13774
14153
  /** A member that opens the group's link again knocks to check it is still in, this long at most (`joinByLink`). */
13775
14154
  var CHECK_MS = 18e4;
13776
14155
  var MESSAGE_LINK$1 = (groupId) => `group:${groupId}`;
@@ -13841,7 +14220,8 @@ var Communities = class {
13841
14220
  }
13842
14221
  /** Is this device a hub of the group? (tests and the load harness) */
13843
14222
  isHub(groupId) {
13844
- return !!this.live.get(groupId)?.hub;
14223
+ const live = this.live.get(groupId);
14224
+ return !!live?.hub && live.session.status === "active";
13845
14225
  }
13846
14226
  async load(groups) {
13847
14227
  for (const group of groups) {
@@ -14185,6 +14565,7 @@ var Communities = class {
14185
14565
  if (live.session.status === "lost" && !group.joining) {
14186
14566
  if (live.hub) {
14187
14567
  live.hub = false;
14568
+ live.leaving = 0;
14188
14569
  await this.publishBeacon(group.id, live, now, false).catch(() => {});
14189
14570
  }
14190
14571
  await this.closeEntries(group.id);
@@ -14237,6 +14618,7 @@ var Communities = class {
14237
14618
  live.hub = true;
14238
14619
  live.hubSince = now;
14239
14620
  live.emptySince = now;
14621
+ live.leaving = 0;
14240
14622
  live.knocksScanned = false;
14241
14623
  await this.publishBeacon(groupId, live, now, true).catch(() => {});
14242
14624
  traceJoin(groupId, "hub.elected");
@@ -14247,12 +14629,37 @@ var Communities = class {
14247
14629
  if (live.hub) {
14248
14630
  const load = this.hubLoad(groupId, live, now);
14249
14631
  if (load > 0) live.emptySince = now;
14250
- const linkedToHub = [...this.host.edges(groupId)].some(([key, id]) => this.peerIsHub(live, key) && this.host.linkReady(id, 2));
14251
- if (now - live.emptySince > this.timings.idleHubMs && others.length >= COMMUNITY_TOPOLOGY.minHubs && now - live.hubSince > this.timings.idleHubMs && linkedToHub) {
14252
- live.hub = false;
14253
- await this.closeEntries(groupId);
14254
- await this.publishBeacon(groupId, live, now, false);
14255
- } 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(() => {});
14256
14663
  }
14257
14664
  if (live.hub) {
14258
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);
@@ -14260,6 +14667,7 @@ var Communities = class {
14260
14667
  this.endFarewells(live, now);
14261
14668
  if (live.hub) {
14262
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);
14263
14671
  const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
14264
14672
  if (now - live.lastLobbyPoll >= lobbyEvery) {
14265
14673
  live.lastLobbyPoll = now;
@@ -14271,26 +14679,41 @@ var Communities = class {
14271
14679
  const edges = this.host.edges(groupId);
14272
14680
  const fresh = new Set(others.map((h) => h.key));
14273
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
+ }
14274
14688
  for (const key of live.myHubs) {
14689
+ if (gone.includes(key)) continue;
14275
14690
  const id = edges.get(key);
14276
14691
  if (id && this.host.linkReady(id, 2)) {
14277
14692
  live.hubWaits.delete(key);
14278
14693
  live.hubsUp.add(key);
14694
+ live.replacing?.delete(key);
14279
14695
  continue;
14280
14696
  }
14281
14697
  const since = live.hubWaits.get(key) ?? now;
14282
14698
  live.hubWaits.set(key, since);
14283
14699
  const back = live.hubsUp.has(key) && !!id && !!this.host.linkBack?.(id);
14284
- if (now - since > (back ? 3 : 1) * this.timings.hubWaitMs) {
14700
+ if (now - since > (back || live.replacing?.has(key) ? 3 : 1) * this.timings.hubWaitMs) {
14285
14701
  live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
14286
14702
  live.hubWaits.delete(key);
14287
14703
  live.hubsUp.delete(key);
14704
+ live.replacing?.delete(key);
14288
14705
  }
14289
14706
  }
14290
14707
  let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
14291
14708
  kept = kept.slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
14292
14709
  const picked = pickHubs(me, others, now, new Set(live.hubsAvoided.keys()));
14293
- 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
+ }
14294
14717
  live.myHubs = kept;
14295
14718
  live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
14296
14719
  for (const hub of kept) {
@@ -14340,8 +14763,32 @@ var Communities = class {
14340
14763
  if (id && !this.host.linkReady(id, 2)) this.host.expectPeer(id);
14341
14764
  }
14342
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
+ }
14343
14785
  hubLoad(groupId, live, now) {
14344
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
+ }
14345
14792
  for (const [key, since] of live.members) {
14346
14793
  const id = edges.get(key);
14347
14794
  if (id && this.host.linkReady(id, 2)) live.members.set(key, now);
@@ -14383,6 +14830,7 @@ var Communities = class {
14383
14830
  const n = live.members.size;
14384
14831
  return n >= this.capacity(groupId, live, now, false) ? Math.max(n, COMMUNITY_TOPOLOGY.hubCapacity) : n;
14385
14832
  }
14833
+ /** Reads the beacon; false when the reading did not happen (the relays held it back, or none answered). */
14386
14834
  async readBeacon(groupId, live, now) {
14387
14835
  live.lastBeaconRead = now;
14388
14836
  const keys = beaconKeys(live.session.state.rv, groupId);
@@ -14391,13 +14839,63 @@ var Communities = class {
14391
14839
  live.beaconFailedAt = now;
14392
14840
  live.beaconAskedAt ||= now;
14393
14841
  if (live.session.roster.length === 1 || now - live.beaconAskedAt >= COMMUNITY_TOPOLOGY.beaconFreshMs) live.beaconKnown = true;
14394
- return;
14842
+ return false;
14395
14843
  }
14396
14844
  live.beaconFailedAt = 0;
14397
14845
  live.beaconKnown = true;
14398
- live.beacon = readBeacon(keys, records ?? []);
14846
+ live.beacon = this.inMyTime(live, readBeacon(keys, records ?? []), now);
14399
14847
  live.head = readBeaconHead(keys, records ?? []);
14848
+ live.beaconAt = now;
14400
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
+ });
14401
14899
  }
14402
14900
  /**
14403
14901
  * The newest message frame I hold, for the beacon's head (WISP 9xx § Head): what a member's other profile on the
@@ -14422,7 +14920,7 @@ var Communities = class {
14422
14920
  const read = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
14423
14921
  if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
14424
14922
  const records = read ?? [];
14425
- existing = readBeacon(keys, records);
14923
+ existing = this.inMyTime(live, readBeacon(keys, records), now);
14426
14924
  head = readBeaconHead(keys, records);
14427
14925
  }
14428
14926
  const hubs = mergeBeacon(existing, live.session.myKey, listed ? {
@@ -14432,12 +14930,12 @@ var Communities = class {
14432
14930
  since: live.hubSince || now
14433
14931
  } : null, now, (key) => !live.session.wasRemoved(key));
14434
14932
  const newest = newerHead(head, this.newestFrame(live), now);
14435
- 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);
14436
14934
  live.beacon = hubs;
14437
14935
  live.head = newest;
14438
14936
  this.noteHubs(live, now);
14439
14937
  live.lastBeaconWrite = now;
14440
- live.lastBeaconRead = now;
14938
+ live.lastBeaconRead = live.beaconAt = now;
14441
14939
  live.beaconFailedAt = 0;
14442
14940
  live.beaconKnown = true;
14443
14941
  }
@@ -14515,10 +15013,11 @@ var Communities = class {
14515
15013
  async askHub(groupId, live, hub, now) {
14516
15014
  const keys = lobbyKeys(live.session.state.rv, groupId, hub);
14517
15015
  const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true) ?? []);
15016
+ const theirs = now - this.clockAhead(live, hub);
14518
15017
  await this.host.publish(keys.identity, lobbyRecords(keys, mergeLobby(existing, {
14519
15018
  key: live.session.myKey,
14520
- ts: now
14521
- }, now)), true);
15019
+ ts: theirs
15020
+ }, theirs)), true);
14522
15021
  traceJoin(groupId, "lobby.written");
14523
15022
  }
14524
15023
  async pollLobby(groupId, live, now) {
@@ -14712,9 +15211,15 @@ var Communities = class {
14712
15211
  this.host.emit();
14713
15212
  }
14714
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
+ */
14715
15219
  async closeEntries(groupId) {
14716
- this.live.get(groupId)?.pendingEntries.clear();
14717
- 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);
14718
15223
  }
14719
15224
  /** An entry session of a community came up with groups v2 on both sides: the hub invites. */
14720
15225
  entryReady(groupId, linkId, peer) {
@@ -14824,6 +15329,12 @@ var Communities = class {
14824
15329
  live.expect.add(joining.inviter);
14825
15330
  live.lobbyWrites.set(joining.inviter, now);
14826
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
+ });
14827
15338
  await this.store.putGroup(member);
14828
15339
  if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
14829
15340
  else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
@@ -14881,14 +15392,16 @@ var Communities = class {
14881
15392
  if (live.relayed.size > RELAYED_KEPT) live.relayed.delete(live.relayed.values().next().value);
14882
15393
  return true;
14883
15394
  }
15395
+ /** True when the edge took the frame. */
14884
15396
  sendTo(linkId, frame) {
14885
- if (!linkId || !this.host.linkReady(linkId, 2)) return;
15397
+ if (!linkId || !this.host.linkReady(linkId, 2)) return false;
14886
15398
  try {
14887
15399
  this.host.sendOnLink(linkId, frame);
14888
15400
  } catch {
14889
- return;
15401
+ return false;
14890
15402
  }
14891
15403
  this.noteTaken(frame);
15404
+ return true;
14892
15405
  }
14893
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. */
14894
15407
  noteTaken(frame) {
@@ -14923,9 +15436,7 @@ var Communities = class {
14923
15436
  group.community = next;
14924
15437
  await this.store.putGroup(group);
14925
15438
  },
14926
- broadcast: (frame) => {
14927
- for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
14928
- },
15439
+ broadcast: (frame) => this.hearers(id, frame).filter(([, linkId]) => this.sendTo(linkId, frame)).length,
14929
15440
  direct: (to, frame) => {
14930
15441
  if (this.mayHear(this.live.get(id), to, frame)) this.sendTo(this.host.edges(id).get(to), frame);
14931
15442
  },
@@ -14956,7 +15467,7 @@ var Communities = class {
14956
15467
  ...m.forwarded && { forwarded: m.forwarded },
14957
15468
  ...m.card && { card: m.card }
14958
15469
  });
14959
- const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, this.now());
15470
+ const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
14960
15471
  this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
14961
15472
  if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
14962
15473
  if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
@@ -14985,10 +15496,15 @@ var Communities = class {
14985
15496
  hub: false,
14986
15497
  hubSince: 0,
14987
15498
  beacon: [],
15499
+ hubClocks: /* @__PURE__ */ new Map(),
15500
+ beaconSeenAt: 0,
14988
15501
  head: null,
14989
15502
  lastBeaconRead: 0,
14990
15503
  beaconKnown: false,
14991
15504
  beaconFailedAt: 0,
15505
+ beaconAt: 0,
15506
+ leaving: 0,
15507
+ stepDownAt: 0,
14992
15508
  lastBeaconWrite: 0,
14993
15509
  lastBeaconTry: 0,
14994
15510
  hubCandidateAt: 0,
@@ -15474,7 +15990,7 @@ var MeshHubs = class {
15474
15990
  ...legacy.length ? { l: legacy } : {}
15475
15991
  };
15476
15992
  for (const [key, id] of edges) {
15477
- if (only && key !== only || !this.host.linkReady(id)) continue;
15993
+ if (only && key !== only || !rosterHas(session.roster, key) || !this.host.linkReady(id)) continue;
15478
15994
  try {
15479
15995
  this.host.sendOnLink(id, frame);
15480
15996
  } catch {}
@@ -15568,8 +16084,9 @@ var MeshHubs = class {
15568
16084
  for (const [key, id] of this.host.edges(groupId)) {
15569
16085
  if (key === from || !this.host.linkReady(id)) continue;
15570
16086
  const member = rosterHas(session.roster, key);
16087
+ const until = member ? Infinity : removalEpoch(session.state.chain, key);
15571
16088
  for (const frame of frames) {
15572
- 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;
15573
16090
  try {
15574
16091
  this.host.sendOnLink(id, frame);
15575
16092
  sent++;
@@ -15581,6 +16098,14 @@ var MeshHubs = class {
15581
16098
  return sent;
15582
16099
  }
15583
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
+ }
15584
16109
  function authorOf(frame) {
15585
16110
  switch (frame.t) {
15586
16111
  case "group-msg":
@@ -15668,7 +16193,7 @@ var GroupTypings = class {
15668
16193
  * knocking, and stayed "invited" for 10 minutes (2026-09-30).
15669
16194
  */
15670
16195
  function otherEndSeen(presence, dataLink, now = Date.now()) {
15671
- return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presence.lastPacketAt < 3e4;
16196
+ return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presenceSeenAt(presence) < 3e4;
15672
16197
  }
15673
16198
  /** The key an edit of message `id` goes by in `FramesTaken`. */
15674
16199
  var editKey = (id, e) => `${id}#e${e}`;
@@ -15721,6 +16246,13 @@ function eventTime(taken, groupId, timestamp) {
15721
16246
  return timestamp;
15722
16247
  }
15723
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
+ /**
15724
16256
  * Keeps on the group's record when a member's message came, where that is later than what it holds: the record is
15725
16257
  * saved with the session right after (a message taken moves its state), so a message handed to me late still makes
15726
16258
  * the group unread after the app starts again. Read from the history alone, by its own time, it sorted among what I
@@ -15763,12 +16295,19 @@ var REFUSED_FOR_MS = 6e5;
15763
16295
  * doubling with each timeout in a row up to `REFUSED_FOR_MS`: a slow network is no refusal.
15764
16296
  */
15765
16297
  var ENTRY_RETRY_MS = 3e4;
15766
- /** 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
+ */
15767
16302
  var ENTRY_LINGER_MS = 2e4;
15768
16303
  /** Taken out of a group, my edges stay this long: as a hub, the commit that removed me is passed on over them. */
15769
16304
  var REMOVED_LINGER_MS = 15e3;
15770
16305
  /** How long the tombstone of a group I left waits for the admin to hear it. */
15771
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;
15772
16311
  /** Where a member's gossip turns start among the members it is connected to: a number of its own, from its key. */
15773
16312
  var gossipStart = (key) => {
15774
16313
  let n = 0;
@@ -15853,6 +16392,15 @@ var Groups = class {
15853
16392
  knockRewrites = /* @__PURE__ */ new Map();
15854
16393
  /** Per group, members met over their admission a moment ago: their edge is opened expecting them. */
15855
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();
15856
16404
  /** Joiner side: groups whose knock is published, for the stage the joiner is shown. */
15857
16405
  knocked = /* @__PURE__ */ new Set();
15858
16406
  /**
@@ -16216,6 +16764,8 @@ var Groups = class {
16216
16764
  delete group.entry;
16217
16765
  this.invited.delete(groupId);
16218
16766
  this.pendingEntries.delete(groupId);
16767
+ this.welcomed.delete(groupId);
16768
+ this.lingering.delete(groupId);
16219
16769
  this.lastMessageAt.delete(groupId);
16220
16770
  this.lastPeerMessageAt.delete(groupId);
16221
16771
  this.lastMentionAt.delete(groupId);
@@ -16248,14 +16798,127 @@ var Groups = class {
16248
16798
  }
16249
16799
  async remove(groupId, key) {
16250
16800
  if (this.isCommunity(groupId)) return this.communities.remove(groupId, key);
16251
- await this.session(groupId).remove(key);
16252
- 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;
16253
16816
  if (contact) try {
16254
16817
  this.host.sendOnLink(contact, {
16255
16818
  t: "group-removed",
16256
16819
  g: groupId
16257
16820
  });
16821
+ told = true;
16822
+ } catch {}
16823
+ if (noted && told) this.dropFarewell(group, key);
16824
+ if (noted) {
16825
+ await this.store.putGroup(group);
16826
+ this.reconcileEdges(groupId);
16827
+ }
16828
+ }
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
+ }]]);
16836
+ }
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
+ }
16258
16919
  } catch {}
16920
+ farewell.told ??= this.now();
16921
+ this.store.putGroup(group).catch(() => {});
16259
16922
  }
16260
16923
  makeAdmin(groupId, key) {
16261
16924
  return this.isCommunity(groupId) ? this.communities.makeAdmin(groupId, key) : this.session(groupId).transferAdmin(key);
@@ -16297,6 +16960,8 @@ var Groups = class {
16297
16960
  this.knockCheckAt.delete(groupId);
16298
16961
  this.knockRewrites.delete(groupId);
16299
16962
  this.justMet.delete(groupId);
16963
+ this.welcomed.delete(groupId);
16964
+ this.lingering.delete(groupId);
16300
16965
  this.relayAsked.delete(groupId);
16301
16966
  this.hubs.forget(groupId);
16302
16967
  this.removedAt.delete(groupId);
@@ -16428,6 +17093,7 @@ var Groups = class {
16428
17093
  }
16429
17094
  for (const [key, until] of this.refused) if (until <= now) this.refused.delete(key);
16430
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);
16431
17097
  this.meshTick(now);
16432
17098
  await this.communities.tick(now);
16433
17099
  await this.hubsTick(now);
@@ -16449,7 +17115,7 @@ var Groups = class {
16449
17115
  if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
16450
17116
  await this.hubs.tick(groupId, session, group, now).catch(() => false);
16451
17117
  if (this.hubReconcile.has(groupId)) continue;
16452
- const wanted = this.hubs.edgesWanted(groupId, session, group, now);
17118
+ const wanted = this.edgesWanted(groupId, session, group, now);
16453
17119
  const edges = this.host.edges(groupId);
16454
17120
  if ([...wanted].some((key) => !edges.has(key)) || [...edges.keys()].some((key) => !wanted.has(key))) {
16455
17121
  this.hubReconcile.add(groupId);
@@ -16496,8 +17162,16 @@ var Groups = class {
16496
17162
  seedB64: group.entry.seedB64
16497
17163
  } : void 0;
16498
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
+ }
16499
17172
  async closeEntries(groupId) {
16500
17173
  this.pendingEntries.delete(groupId);
17174
+ this.welcomed.delete(groupId);
16501
17175
  const invited = this.invited.get(groupId);
16502
17176
  for (const linkId of this.host.entries(groupId).values()) {
16503
17177
  invited?.delete(linkId);
@@ -16650,7 +17324,11 @@ var Groups = class {
16650
17324
  this.entryTimeouts.delete(entryPeer);
16651
17325
  this.pendingEntries.get(g)?.delete(entryPeer);
16652
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);
16653
17330
  setTimeout(() => {
17331
+ if (this.welcomed.get(g)?.get(entryPeer) === linkId) this.welcomed.get(g).delete(entryPeer);
16654
17332
  if (this.host.entries(g).get(entryPeer) === linkId) this.host.closeEdge(linkId);
16655
17333
  }, ENTRY_LINGER_MS);
16656
17334
  }
@@ -16698,6 +17376,11 @@ var Groups = class {
16698
17376
  };
16699
17377
  this.stored.set(g, member);
16700
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
+ });
16701
17384
  await this.store.putGroup(member);
16702
17385
  if (!viaLink) await this.sessions.get(g).setNick(group.invitation.admin, this.host.contactName(linkId));
16703
17386
  await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE}`, Date.now(), joined.state.chain.length - 1);
@@ -16705,12 +17388,18 @@ var Groups = class {
16705
17388
  if (viaLink) {
16706
17389
  this.lastKnock.delete(g);
16707
17390
  this.knocked.delete(g);
16708
- await this.host.closeEdge(linkId);
16709
17391
  }
16710
17392
  this.reconcileEdges(g);
16711
17393
  this.host.emit();
16712
17394
  return;
16713
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
+ }
16714
17403
  case "group-removed": {
16715
17404
  const session = this.sessions.get(g);
16716
17405
  const group = this.stored.get(g);
@@ -16738,6 +17427,10 @@ var Groups = class {
16738
17427
  }
16739
17428
  }
16740
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
+ }
16741
17434
  /** A `group-*` frame on an edge: from the member the edge is pinned to. */
16742
17435
  async handleEdgeFrame(groupId, peerKey, frame) {
16743
17436
  if (this.isCommunity(groupId)) return this.communities.handleEdgeFrame(groupId, peerKey, frame);
@@ -16746,6 +17439,12 @@ var Groups = class {
16746
17439
  return;
16747
17440
  }
16748
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;
16749
17448
  if (session?.status === "active" && frame && typeof frame === "object" && frame.t === "group-here") {
16750
17449
  this.heardHere(groupId, session, peerKey, frame);
16751
17450
  return;
@@ -16834,6 +17533,10 @@ var Groups = class {
16834
17533
  return;
16835
17534
  }
16836
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
+ }
16837
17540
  if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
16838
17541
  const large = this.host.linkReady(linkId, 3) && this.markLarge(group, peerKey);
16839
17542
  const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
@@ -16843,6 +17546,8 @@ var Groups = class {
16843
17546
  } catch {
16844
17547
  return;
16845
17548
  }
17549
+ if (this.lingering.get(groupId)?.admin === peerKey) this.endLinger(groupId).catch(() => {});
17550
+ this.welcomed.get(groupId)?.delete(peerKey);
16846
17551
  this.announceHere(groupId, session, peerKey);
16847
17552
  this.host.edgeUp?.(groupId, peerKey);
16848
17553
  this.host.emit();
@@ -16962,6 +17667,7 @@ var Groups = class {
16962
17667
  if (reconcile) this.lastReconcile = now;
16963
17668
  for (const [groupId, session] of this.sessions) {
16964
17669
  if (session.status !== "active" || this.stored.get(groupId)?.left) continue;
17670
+ this.farewellsTick(groupId, session, now);
16965
17671
  if (reconcile) {
16966
17672
  const edges = this.host.edges(groupId);
16967
17673
  if (!this.hubs.large(session) && session.others.some((key) => !edges.has(key))) this.reconcileEdges(groupId);
@@ -16985,12 +17691,25 @@ var Groups = class {
16985
17691
  } catch {}
16986
17692
  }
16987
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
+ }
16988
17705
  edgeNick(groupId, peerKey, nick) {
16989
17706
  if (this.isCommunity(groupId)) {
16990
17707
  this.communities.edgeNick(groupId, peerKey, nick);
16991
17708
  return;
16992
17709
  }
16993
- 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);
16994
17713
  }
16995
17714
  session(groupId) {
16996
17715
  const session = this.sessions.get(groupId);
@@ -17008,10 +17727,14 @@ var Groups = class {
17008
17727
  },
17009
17728
  send: (to, frame) => {
17010
17729
  const edge = this.host.edges(state.id).get(to);
17011
- if (!edge) return;
17012
17730
  try {
17731
+ if (!edge) throw new Error("no edge");
17013
17732
  this.host.sendOnLink(edge, frame);
17014
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 {}
17015
17738
  return;
17016
17739
  }
17017
17740
  if ((frame.t === "group-msg" || frame.t === "group-edit") && frame.s === session.myKey) {
@@ -17036,7 +17759,7 @@ var Groups = class {
17036
17759
  ...m.card && { card: m.card }
17037
17760
  };
17038
17761
  const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
17039
- const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, this.now());
17762
+ const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
17040
17763
  this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
17041
17764
  if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
17042
17765
  if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
@@ -17110,7 +17833,7 @@ var Groups = class {
17110
17833
  const existing = this.host.edges(groupId);
17111
17834
  const left = group?.left;
17112
17835
  const removed = this.removedAt.get(groupId);
17113
- 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();
17114
17837
  for (const [key, linkId] of existing) if (!wanted.has(key)) await this.host.closeEdge(linkId);
17115
17838
  const met = this.justMet.get(groupId);
17116
17839
  if (session) {
@@ -17160,7 +17883,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
17160
17883
  linkId: live.stored.id,
17161
17884
  state: "waiting",
17162
17885
  noSlot: true,
17163
- lastSeenAt: Math.max(live.presence.lastPacketAt, live.lastSyncAt)
17886
+ lastSeenAt: Math.max(presenceSeenAt(live.presence), live.lastSyncAt)
17164
17887
  };
17165
17888
  const error = open ? void 0 : live.pairing?.status === "error" ? live.pairing.error : live.discoveryError;
17166
17889
  const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
@@ -17168,7 +17891,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
17168
17891
  linkId: live.stored.id,
17169
17892
  state,
17170
17893
  ...open && live.pairing?.transport ? { transport: live.pairing.transport } : {},
17171
- lastSeenAt: open ? now : Math.max(live.presence.lastPacketAt, live.lastSyncAt),
17894
+ lastSeenAt: open ? now : Math.max(presenceSeenAt(live.presence), live.lastSyncAt),
17172
17895
  ...error ? { error } : {}
17173
17896
  };
17174
17897
  }
@@ -18338,7 +19061,7 @@ var HoldEngine = class {
18338
19061
  name: heldName(storage.space, mailbox, seq),
18339
19062
  bytes: item.bytes,
18340
19063
  ts: item.timestamp,
18341
- expires: now + this.ttl(),
19064
+ expires: now + Math.min(this.ttl(), HOLD_LIMITS.ttlMs - HOLD_DATED_BACK_MS),
18342
19065
  state: "queued"
18343
19066
  };
18344
19067
  await this.save(linkId, {
@@ -18536,7 +19259,7 @@ var HoldEngine = class {
18536
19259
  const now = this.now();
18537
19260
  const held = hold.outbox.filter((e) => e.state === "held");
18538
19261
  let manifestUrl = null;
18539
- const expires = now + HOLD_LIMITS.ttlMs;
19262
+ const dated = now - HOLD_DATED_BACK_MS, expires = dated + HOLD_LIMITS.ttlMs;
18540
19263
  if (held.length && storage && hold.mailbox) {
18541
19264
  if (rewriteManifest || !hold.manifestSignedAt) {
18542
19265
  const entries = [];
@@ -18552,7 +19275,7 @@ var HoldEngine = class {
18552
19275
  mailbox: hold.mailbox,
18553
19276
  seq: hold.pointerRev + 1,
18554
19277
  id: `manifest-${hold.pointerRev + 1}`,
18555
- ts: now,
19278
+ ts: dated,
18556
19279
  kind: "manifest",
18557
19280
  meta: { entries },
18558
19281
  expires
@@ -18573,7 +19296,7 @@ var HoldEngine = class {
18573
19296
  }
18574
19297
  const pointer = {
18575
19298
  rev: hold.pointerRev + 1,
18576
- issued: now,
19299
+ issued: dated,
18577
19300
  expires,
18578
19301
  manifestUrl,
18579
19302
  top: hold.outSeq,
@@ -18650,7 +19373,7 @@ var HoldEngine = class {
18650
19373
  this.host.changed();
18651
19374
  return;
18652
19375
  }
18653
- const pointer = packet ? keys.readPointer(packet, now) : null;
19376
+ const pointer = packet ? keys.readPointer(packet) : null;
18654
19377
  if (!pointer) return;
18655
19378
  let hold = this.state(linkId);
18656
19379
  if (pointer.rev < hold.peerPointerRev) return;
@@ -19685,7 +20408,7 @@ var ProfilePeek = class {
19685
20408
  const keys = new HoldKeys(link, link.participationSeed, link.pairedPeerKey);
19686
20409
  const packet = await transport.resolve(keys.peerAddress, { background: true });
19687
20410
  const now = this.now(), hold = link.hold;
19688
- const pointer = packet ? keys.readPointer(packet, now) : null;
20411
+ const pointer = packet ? keys.readPointer(packet) : null;
19689
20412
  if (pointer && pointer.rev >= hold.peerPointerRev && pointer.expires > now && pointer.top > hold.inSeq) held = Math.min(pointer.count, pointer.top - hold.inSeq);
19690
20413
  }
19691
20414
  return {
@@ -19960,6 +20683,10 @@ var S3Store = class {
19960
20683
  *
19961
20684
  * Secrets are random, so there is no seed to restore from yet. `exportTokens`
19962
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.
19963
20690
  */
19964
20691
  var UNIT$1 = "sat";
19965
20692
  var QUOTE_POLL_MS = 4e3;
@@ -19969,11 +20696,37 @@ var MAX_AMOUNT = 1e6;
19969
20696
  /** How long "Get test coins" waits for the test mint to mark its invoice paid before saying the coins come later. */
19970
20697
  var TEST_COINS_WAIT_MS = 3e4;
19971
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;
19972
20707
  function reviewedCashuFee(preview) {
19973
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();
19974
20709
  if (!Number.isSafeInteger(fee) || fee < 0) throw new Error("Invalid Cashu prepared fee");
19975
20710
  return fee;
19976
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;
19977
20730
  function normalizeMintUrl(input) {
19978
20731
  let url;
19979
20732
  try {
@@ -20004,6 +20757,7 @@ var toStored = (mint, p, reserved) => ({
20004
20757
  });
20005
20758
  /** The note of the ecash "Get test coins" brought (the engine plays its own sound for it). */
20006
20759
  var TEST_COINS_NOTE = "Test coins from the test mint";
20760
+ var swapId = () => `swap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
20007
20761
  var walletTx = (mint, kind, amount, fee, note) => ({
20008
20762
  id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
20009
20763
  timestamp: Date.now(),
@@ -20037,8 +20791,13 @@ var CashuWallet = class {
20037
20791
  locks = /* @__PURE__ */ new Map();
20038
20792
  quoteTimer = null;
20039
20793
  meltTimer = null;
20794
+ restoredTimer = null;
20795
+ swapTimer = null;
20796
+ swapTimerAt = 0;
20040
20797
  /** Per melt quote that ended unpaid: the sats the mint kept all the same (the fee of the split before the melt). */
20041
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();
20042
20801
  /**
20043
20802
  * `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
20044
20803
  * invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
@@ -20052,6 +20811,8 @@ var CashuWallet = class {
20052
20811
  start() {
20053
20812
  this.pollQuotes();
20054
20813
  this.pollMelts();
20814
+ this.checkRestored();
20815
+ this.pollSwaps();
20055
20816
  for (const mint of this.getKnownMints()) this.checkMint(mint).then(() => this.events.onChange(), () => {});
20056
20817
  }
20057
20818
  /** The Cashu wallet of one network: its mints and their balance. History is every network's, newest first. */
@@ -20065,13 +20826,57 @@ var CashuWallet = class {
20065
20826
  }));
20066
20827
  const all = await wrap((await store(STORES.walletTx, "readonly")).getAll());
20067
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)));
20068
20833
  return {
20069
20834
  mints,
20070
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) }))),
20071
20840
  history: all.slice(0, HISTORY_SHOWN),
20072
20841
  feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
20073
20842
  };
20074
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
+ }
20075
20880
  /** Talks to the mint before it is added: a typo should not become a place to keep money. */
20076
20881
  async checkMint(url) {
20077
20882
  const normalized = normalizeMintUrl(url);
@@ -20232,12 +21037,16 @@ var CashuWallet = class {
20232
21037
  const quote = await wrap((await store(STORES.quotes, "readonly")).get(id));
20233
21038
  if (!quote || quote.issuedUnclaimed || quote.held) return null;
20234
21039
  const wallet = await this.wallet(quote.mint);
20235
- const { state } = await wallet.checkMintQuoteBolt11(quote.quote);
21040
+ const answer = await wallet.checkMintQuoteBolt11(quote.quote);
21041
+ const { state } = answer;
20236
21042
  if (state === "UNPAID") {
20237
21043
  if (quote.expiresAt && quote.expiresAt + 6e4 < Date.now()) await wrap((await store(STORES.quotes, "readwrite")).delete(quote.quote));
20238
21044
  return null;
20239
21045
  }
21046
+ const saved = quote.outputs?.map((o) => OutputData.deserialize(o));
20240
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);
20241
21050
  console.warn(`[wallet] ${quote.mint} issued quote ${quote.quote} (${quote.amount} sats) but this wallet holds no ecash for it`);
20242
21051
  await wrap((await store(STORES.quotes, "readwrite")).put({
20243
21052
  ...quote,
@@ -20245,11 +21054,31 @@ var CashuWallet = class {
20245
21054
  }));
20246
21055
  return null;
20247
21056
  }
20248
- 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 = {
20249
21063
  ...quote,
20250
- paid: true
20251
- }));
20252
- 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) {
20253
21082
  const minted = sats(proofs);
20254
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);
20255
21084
  await transact([
@@ -20286,24 +21115,24 @@ var CashuWallet = class {
20286
21115
  for (const mint of candidates) {
20287
21116
  if (await this.balanceAt(mint) < amount) continue;
20288
21117
  return this.locked(mint, async () => {
20289
- const wallet = await this.wallet(mint);
20290
- const proofs = await this.proofsAt(mint);
20291
- 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));
20292
21119
  const token = getEncodedToken({
20293
21120
  mint,
20294
21121
  proofs: send,
20295
21122
  unit: UNIT$1
20296
21123
  });
20297
- const spent = total(proofs) - sats(keep);
21124
+ const spent = total(inputs) - sats(keep);
20298
21125
  const tx = walletTx(mint, "ecash-out", amount, spent - amount, note);
20299
21126
  const kept = outbox?.(token, mint);
20300
21127
  await transact([
20301
21128
  STORES.proofs,
20302
21129
  STORES.walletTx,
21130
+ STORES.swaps,
20303
21131
  ...kept ? [STORES.payments] : []
20304
21132
  ], (stores) => {
20305
- this.queueReplace(stores[STORES.proofs], mint, proofs, keep);
21133
+ this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
20306
21134
  stores[STORES.walletTx].put(tx);
21135
+ if (swap) stores[STORES.swaps].delete(swap.id);
20307
21136
  if (kept) stores[STORES.payments].put(kept);
20308
21137
  });
20309
21138
  this.events.onChange();
@@ -20366,18 +21195,41 @@ var CashuWallet = class {
20366
21195
  const keep = (prepared.swap.keepOutputs ?? []).map(OutputData.deserialize);
20367
21196
  const send = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
20368
21197
  const outputs = [...keep, ...send];
20369
- const recovered = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
20370
- if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return void 0;
20371
- const keys = (await wallet.mint.getKeys()).keysets;
20372
- const restored = [];
20373
- for (const output of outputs) {
20374
- const index = recovered.outputs.findIndex((o) => o.B_ === output.blindedMessage.B_);
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) {
21220
+ const recovered = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
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";
21223
+ const keys = (await wallet.mint.getKeys()).keysets;
21224
+ const restored = [];
21225
+ for (const output of outputs) {
21226
+ const index = recovered.outputs.findIndex((o) => o.B_ === output.blindedMessage.B_);
20375
21227
  const signature = recovered.signatures[index];
20376
21228
  const key = signature && keys.find((k) => k.id === signature.id);
20377
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");
20378
21230
  restored.push(output.toProof(signature, key));
20379
21231
  }
20380
- return this.finishReviewedCashu(review, prepared, restored.slice(0, keep.length), restored.slice(keep.length));
21232
+ return restored;
20381
21233
  }
20382
21234
  async finishReviewedCashu(review, prepared, keep, send) {
20383
21235
  const token = getEncodedToken({
@@ -20434,6 +21286,72 @@ var CashuWallet = class {
20434
21286
  const restored = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
20435
21287
  return restored.signatures.length === 0 && restored.outputs.length === 0;
20436
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
+ }
20437
21355
  /** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
20438
21356
  async releaseReviewedCashu(prepared) {
20439
21357
  const preview = deserializeSwapPreview(prepared.swap);
@@ -20453,6 +21371,13 @@ var CashuWallet = class {
20453
21371
  });
20454
21372
  this.events.onChange();
20455
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
+ }
20456
21381
  async reviewedCashuSpent(prepared) {
20457
21382
  const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
20458
21383
  const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
@@ -20508,18 +21433,40 @@ var CashuWallet = class {
20508
21433
  if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
20509
21434
  if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
20510
21435
  return this.locked(mint, async () => {
20511
- const proofs = await (await this.wallet(mint)).receive(token);
20512
- const credited = sats(proofs);
20513
- const tx = walletTx(mint, kind, credited, Math.max(0, faceValue - credited), note);
20514
- await transact([
20515
- STORES.proofs,
20516
- STORES.walletTx,
20517
- ...payment ? [STORES.payments] : []
20518
- ], (stores) => {
20519
- for (const p of proofs) stores[STORES.proofs].put(toStored(mint, p));
20520
- stores[STORES.walletTx].put(tx);
20521
- 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) } : {}
20522
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);
20523
21470
  this.events.onChange();
20524
21471
  return {
20525
21472
  amount: credited,
@@ -20527,6 +21474,334 @@ var CashuWallet = class {
20527
21474
  };
20528
21475
  });
20529
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
+ }
20530
21805
  /** What paying this invoice would cost, from the first mint that can afford it. */
20531
21806
  async quoteInvoice(invoice, network) {
20532
21807
  let lastError = /* @__PURE__ */ new Error("Add a mint in Settings first");
@@ -20557,7 +21832,8 @@ var CashuWallet = class {
20557
21832
  /**
20558
21833
  * Pays a melt quote. True once paid; false while the mint holds the payment pending, in which case its
20559
21834
  * proofs stay reserved and the wallet keeps asking until the mint settles it (`onMeltResolved`).
20560
- * 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`.
20561
21837
  */
20562
21838
  async payQuote(quoteId, mint, note, paymentId) {
20563
21839
  try {
@@ -20574,13 +21850,15 @@ var CashuWallet = class {
20574
21850
  const quote = await wallet.checkMeltQuoteBolt11(quoteId);
20575
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");
20576
21852
  const needed = quote.amount.toNumber() + quote.fee_reserve.toNumber();
20577
- const proofs = await this.proofsAt(mint);
20578
- 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));
20579
21854
  let preview;
20580
21855
  try {
20581
21856
  preview = await wallet.prepareMelt("bolt11", quote, send);
20582
21857
  } catch (error) {
20583
- 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
+ });
20584
21862
  throw error;
20585
21863
  }
20586
21864
  const melt = {
@@ -20589,16 +21867,21 @@ var CashuWallet = class {
20589
21867
  request: quote.request,
20590
21868
  amount: quote.amount.toNumber(),
20591
21869
  secrets: send.map((p) => p.secret),
20592
- outlay: total(proofs) - sats(keep),
21870
+ outlay: total(inputs) - sats(keep),
20593
21871
  outputs: preview.outputData.map((o) => OutputData.serialize(o)),
20594
21872
  note: note?.slice(0, 140),
20595
21873
  paymentId,
20596
21874
  createdAt: Date.now()
20597
21875
  };
20598
- await transact([STORES.proofs, STORES.melts], (stores) => {
20599
- 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);
20600
21882
  for (const p of send) stores[STORES.proofs].put(toStored(mint, p, true));
20601
21883
  stores[STORES.melts].put(melt);
21884
+ if (swap) stores[STORES.swaps].delete(swap.id);
20602
21885
  });
20603
21886
  let result;
20604
21887
  try {
@@ -20608,6 +21891,7 @@ var CashuWallet = class {
20608
21891
  const outcome = await this.settleMelt(melt).catch(() => "pending");
20609
21892
  if (outcome === "unpaid") {
20610
21893
  const lost = this.takeFailedFee(melt.quote);
21894
+ if (this.spentAway.delete(melt.quote)) throw Object.assign(engineError("ecashAlreadySpent"), { cause: error });
20611
21895
  if (lost > 0) throw Object.assign(/* @__PURE__ */ new Error(`${error instanceof Error ? error.message : String(error)} ${backInWallet(lost)}`), { cause: error });
20612
21896
  throw error;
20613
21897
  }
@@ -20619,6 +21903,13 @@ var CashuWallet = class {
20619
21903
  }
20620
21904
  return this.settleMelt(melt).catch(() => "pending");
20621
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
+ }
20622
21913
  /** Lightning payments left pending, asked about again until the mint settles each one. */
20623
21914
  async pollMelts() {
20624
21915
  if (this.meltTimer) clearTimeout(this.meltTimer);
@@ -20631,6 +21922,7 @@ var CashuWallet = class {
20631
21922
  });
20632
21923
  if (outcome === "paid" || outcome === "unpaid") {
20633
21924
  this.takeFailedFee(melt.quote);
21925
+ this.spentAway.delete(melt.quote);
20634
21926
  this.events.onMeltResolved(melt, outcome === "paid");
20635
21927
  this.events.onChange();
20636
21928
  }
@@ -20665,7 +21957,7 @@ var CashuWallet = class {
20665
21957
  if (states.some((s) => s.state === "PENDING")) return "pending";
20666
21958
  const spent = new Set(inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
20667
21959
  if (spent.size > 0) console.warn(`[wallet] melt ${melt.quote} is unpaid, yet ${spent.size} of its proofs are spent`);
20668
- 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);
20669
21961
  await transact([
20670
21962
  STORES.proofs,
20671
21963
  STORES.melts,
@@ -20680,6 +21972,10 @@ var CashuWallet = class {
20680
21972
  if (lost > 0) stores[STORES.walletTx].put(walletTx(melt.mint, "fee", 0, lost, melt.note ? `Payment failed: ${melt.note}` : "A Lightning payment failed"));
20681
21973
  });
20682
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
+ }
20683
21979
  return "unpaid";
20684
21980
  }
20685
21981
  /** The fee a failed melt cost, once: for the words that say it failed. */
@@ -20708,14 +22004,22 @@ var CashuWallet = class {
20708
22004
  * and every invoice of theirs are deleted. A Lightning payment still in flight from one of them is not cut off: the
20709
22005
  * removal is refused until it settles.
20710
22006
  */
20711
- async forget(mints) {
22007
+ async forget(mints, acceptLoss = false) {
20712
22008
  const at = new Set(mints);
20713
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.");
20714
22012
  const proofs = (await this.allProofs()).filter((p) => at.has(p.mint));
20715
22013
  const quotes = (await wrap((await store(STORES.quotes, "readonly")).getAll())).filter((q) => at.has(q.mint));
20716
- 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) => {
20717
22020
  for (const p of proofs) stores[STORES.proofs].delete(p.secret);
20718
22021
  for (const q of quotes) stores[STORES.quotes].delete(q.quote);
22022
+ for (const s of swaps) stores[STORES.swaps].delete(s.id);
20719
22023
  });
20720
22024
  for (const mint of mints) {
20721
22025
  this.wallets.delete(mint);
@@ -20771,10 +22075,7 @@ var CashuWallet = class {
20771
22075
  async proofsAt(mint) {
20772
22076
  return (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved);
20773
22077
  }
20774
- /** After a swap: the inputs are spent, `keep` is what came back to us. One transaction, so it is all or nothing. */
20775
- async replaceProofs(mint, inputs, keep) {
20776
- await transact([STORES.proofs], (stores) => this.queueReplace(stores[STORES.proofs], mint, inputs, keep));
20777
- }
22078
+ /** After a swap: the inputs are spent, `keep` is what came back to us. Queued in the caller's transaction: all or nothing. */
20778
22079
  queueReplace(proofStore, mint, inputs, keep) {
20779
22080
  const kept = new Set(keep.map((p) => p.secret));
20780
22081
  for (const p of inputs) if (!kept.has(p.secret)) proofStore.delete(p.secret);
@@ -21456,7 +22757,11 @@ var PaymentDesk = class {
21456
22757
  }
21457
22758
  if (!payment?.token || payment.direction !== "out") throw new Error("Nothing to reclaim");
21458
22759
  try {
21459
- 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
+ }) });
21460
22765
  await this.save({
21461
22766
  ...this.current(payment),
21462
22767
  state: "reclaimed",
@@ -21767,31 +23072,18 @@ var PaymentDesk = class {
21767
23072
  addTestMint: true,
21768
23073
  payment: record
21769
23074
  });
21770
- await this.save(record(amount, mint));
21771
- const request = payment.requestId ? this.payments.get(payment.requestId) : void 0;
21772
- 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))) {
21773
- if (request.group) await this.settleRequest(request, { mint });
21774
- else await this.save({
21775
- ...request,
21776
- state: "settled",
21777
- 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)
21778
23084
  });
23085
+ return;
21779
23086
  }
21780
- await this.host.storeMessage({
21781
- linkId,
21782
- id: `peer_${payment.timestamp}`,
21783
- text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
21784
- sender: "peer",
21785
- timestamp: payment.timestamp,
21786
- via: "datalink",
21787
- paymentId: payment.id
21788
- });
21789
- link?.sendPaymentResult({
21790
- id: payment.id,
21791
- ok: true,
21792
- credited: String(amount)
21793
- });
21794
- } catch (error) {
21795
23087
  const reason = error instanceof Error ? error.message : String(error);
21796
23088
  link?.sendPaymentResult({
21797
23089
  id: payment.id,
@@ -21811,6 +23103,61 @@ var PaymentDesk = class {
21811
23103
  } catch {}
21812
23104
  }
21813
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
+ }
21814
23161
  async onPaymentResult(linkId, result) {
21815
23162
  const payment = this.find(linkId, result.id);
21816
23163
  if (payment?.target?.method === "fedimint" && payment.linkId === linkId && payment.direction === "out" && payment.kind === "payment") {
@@ -23328,6 +24675,20 @@ function knockCue(before, after) {
23328
24675
  }
23329
24676
  /** A group's own line that says I made it or joined it (not someone else joining). */
23330
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
+ }
23331
24692
  var JOIN_NOTICE = /^👋 (?:.+ )?joined$/;
23332
24693
  /** What a newly stored row says, or null when it is quiet. `type`: "message" for the contact's, "sent" for mine. */
23333
24694
  function messageAttention(type, message, { startedAt, newest, now }) {
@@ -23767,6 +25128,8 @@ var SPARE_INVITE_WARM_AFTER_TAKE_MS = 3e4;
23767
25128
  */
23768
25129
  var STARTUP_QUIET_MS = 15e3;
23769
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;
23770
25133
  /** How long a removal waits for a wallet's rail to claim what was already paid to it (tests shorten it). */
23771
25134
  var removalTiming = { claimMs: 3e4 };
23772
25135
  /** Why a removal needs the person's confirmation: what it holds, what it still waits for, and what confirming means. */
@@ -23843,6 +25206,11 @@ var GhostlyNode = class GhostlyNode {
23843
25206
  directPath = new DirectPathWatch(() => {
23844
25207
  if (!this.shuttingDown) this.emitState();
23845
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;
23846
25214
  /**
23847
25215
  * Edges of groups whose WebRTC did not connect in this run of the app (`edgeWithoutRtc`): they go on as an app with
23848
25216
  * no WebRTC does, over a native transport. Not kept: the next start tries WebRTC first again.
@@ -23891,11 +25259,21 @@ var GhostlyNode = class GhostlyNode {
23891
25259
  newestAt = /* @__PURE__ */ new Map();
23892
25260
  /** The last place given in each chat while this engine runs (`arrivalKey`). */
23893
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
+ }
23894
25272
  /** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
23895
25273
  messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
23896
25274
  const attention = messageAttention(type, {
23897
25275
  ...message,
23898
- timestamp: shownTime(message)
25276
+ timestamp: writtenAt(message, this.clockLeads.get(message.linkId))
23899
25277
  }, {
23900
25278
  startedAt: this.feedbackStartedAt,
23901
25279
  newest,
@@ -23947,6 +25325,11 @@ var GhostlyNode = class GhostlyNode {
23947
25325
  deleted: (linkId, messageId) => !!this.links.get(linkId)?.stored.deletedIds?.includes(messageId),
23948
25326
  messageExists: (linkId, messageId) => db.hasMessage(linkId, messageId),
23949
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
+ },
23950
25333
  transfers: this.transfers,
23951
25334
  changed: (delayMs) => this.emitState(delayMs),
23952
25335
  flush: () => this.flushState(),
@@ -24113,7 +25496,8 @@ var GhostlyNode = class GhostlyNode {
24113
25496
  paymentId: melt.paymentId,
24114
25497
  mint: melt.mint
24115
25498
  }),
24116
- 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)
24117
25501
  }, () => this.settings.mints);
24118
25502
  registry;
24119
25503
  providers() {
@@ -24168,7 +25552,7 @@ var GhostlyNode = class GhostlyNode {
24168
25552
  const stored = this.links.get(linkId)?.stored;
24169
25553
  return stored?.group && !stored.groupEntry ? stored.group : parsePayLink(linkId)?.groupId;
24170
25554
  },
24171
- 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()],
24172
25556
  storeMessage: (message) => this.storeMessage(message),
24173
25557
  heldPaymentMethods: (linkId) => {
24174
25558
  const live = this.links.get(linkId);
@@ -24594,11 +25978,14 @@ var GhostlyNode = class GhostlyNode {
24594
25978
  /** Private groups (WISP 900): sessions, admission on contact chats, and the pairwise edges that carry them. */
24595
25979
  groups = new Groups({
24596
25980
  sendOnLink: (linkId, frame) => {
24597
- const link = this.links.get(linkId)?.link;
24598
- if (!link) throw new Error("You are offline");
24599
- 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);
24600
25986
  },
24601
25987
  linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
25988
+ linkOpen: (linkId) => !!this.links.get(linkId)?.link?.isDataLinkOpen,
24602
25989
  myNick: () => this.sharedNick,
24603
25990
  staysOnline: () => this.options.staysOnline ?? (this.options.platform === "desktop" && typeof RTCPeerConnection !== "undefined"),
24604
25991
  peerRoom: (groupId) => {
@@ -24630,7 +26017,7 @@ var GhostlyNode = class GhostlyNode {
24630
26017
  },
24631
26018
  linkBack: (linkId) => {
24632
26019
  const live = this.links.get(linkId);
24633
- return !!live?.lastSyncAt && !!live.presence?.online && live.presence.lastPacketAt > live.lastSyncAt;
26020
+ return !!live?.lastSyncAt && !!live.presence?.online && presenceSeenAt(live.presence) > live.lastSyncAt;
24634
26021
  },
24635
26022
  publish: (identity, records, background) => this.groupTransport.publish(identity, records, { background }),
24636
26023
  resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
@@ -24717,7 +26104,7 @@ var GhostlyNode = class GhostlyNode {
24717
26104
  }
24718
26105
  /** The link payments with `linkId` go over: a chat's or an edge's, or a community member's through the group. */
24719
26106
  paymentLink(linkId) {
24720
- 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);
24721
26108
  }
24722
26109
  /**
24723
26110
  * Payments in groups (WISP 9xx § Payments): the money goes over the edge to one member through the desk, like a
@@ -24736,7 +26123,7 @@ var GhostlyNode = class GhostlyNode {
24736
26123
  member: stored.groupPeer
24737
26124
  } : void 0;
24738
26125
  },
24739
- 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),
24740
26127
  membership: (groupId) => this.membership(groupId),
24741
26128
  send: (linkId, frame) => {
24742
26129
  const pay = parsePayLink(linkId);
@@ -24760,12 +26147,46 @@ var GhostlyNode = class GhostlyNode {
24760
26147
  this.emitState();
24761
26148
  }
24762
26149
  });
24763
- /** 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
+ */
24764
26155
  groupEdges(groupId) {
24765
26156
  const edges = /* @__PURE__ */ new Map();
24766
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);
24767
26158
  return edges;
24768
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
+ }
24769
26190
  constructor(events, options = {}) {
24770
26191
  this.events = events;
24771
26192
  this.options = options;
@@ -24782,6 +26203,7 @@ var GhostlyNode = class GhostlyNode {
24782
26203
  this.localFetch = options.localFetch ?? webLocalFetch;
24783
26204
  this.hold.host.transport = this.transport;
24784
26205
  this.transport.subscribe?.(() => this.emitState());
26206
+ this.clockOff = this.transport.onServerTime?.((time) => this.clock.server(time.source, time.date, time.sent, time.received));
24785
26207
  }
24786
26208
  /** The Desktop reaches the DHT itself: the relays in Settings are written to, and read from only when the person chose so. */
24787
26209
  configureDirect() {
@@ -24865,6 +26287,10 @@ var GhostlyNode = class GhostlyNode {
24865
26287
  networks[network] = {
24866
26288
  mints: this.withWaits(view.mints, awaiting.mints),
24867
26289
  balance: view.balance,
26290
+ setAside: view.setAside,
26291
+ openSwaps: view.openSwaps,
26292
+ swapsAmount: view.swapsAmount,
26293
+ unconfirmed: view.unconfirmed,
24868
26294
  history,
24869
26295
  feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0),
24870
26296
  ark: this.arkWallets[network].view,
@@ -25018,6 +26444,7 @@ var GhostlyNode = class GhostlyNode {
25018
26444
  const files = linkFilesFrom(stored.id, storedFiles, messages);
25019
26445
  this.links.set(stored.id, newLiveLink(stored, messages[messages.length - 1]?.timestamp ?? 0, files));
25020
26446
  history.set(stored.id, messages);
26447
+ for (const message of messages.slice(-20)) this.clockSample(message);
25021
26448
  const note = latestReaction(messages);
25022
26449
  if (note) this.reactionNotes.set(stored.id, note);
25023
26450
  if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
@@ -25087,6 +26514,8 @@ var GhostlyNode = class GhostlyNode {
25087
26514
  this.shuttingDown = true;
25088
26515
  this.depart();
25089
26516
  this.directPath.close();
26517
+ this.clock.close();
26518
+ this.clockOff?.();
25090
26519
  if (this.relayRetry) clearTimeout(this.relayRetry);
25091
26520
  if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
25092
26521
  for (const timer of this.resumeTimers.values()) clearTimeout(timer);
@@ -25156,6 +26585,7 @@ var GhostlyNode = class GhostlyNode {
25156
26585
  ...this.transport.discovery && { discovery: this.transport.discovery() },
25157
26586
  ...typeof RTCPeerConnection === "undefined" && { webrtc: false },
25158
26587
  ...this.directPath.blocked && { directBlocked: true },
26588
+ ...this.clock.offset !== null && { clockOffMs: this.clock.offset },
25159
26589
  ...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
25160
26590
  },
25161
26591
  links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
@@ -25236,6 +26666,22 @@ var GhostlyNode = class GhostlyNode {
25236
26666
  before: from
25237
26667
  });
25238
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
+ }
25239
26685
  async createLink() {
25240
26686
  const { mine, inviteCode } = this.takeInvite();
25241
26687
  return {
@@ -25263,6 +26709,16 @@ var GhostlyNode = class GhostlyNode {
25263
26709
  inviteCode: spare.inviteCode
25264
26710
  };
25265
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
+ }
25266
26722
  makeSpare() {
25267
26723
  const { mine, invite, inviteCode } = createChatInvite();
25268
26724
  return {
@@ -25288,7 +26744,7 @@ var GhostlyNode = class GhostlyNode {
25288
26744
  spare.warmedAt ??= Date.now();
25289
26745
  for (const identity of [identityFromSeedB64(spare.mine.seedB64), spare.inviteKey]) {
25290
26746
  this.warmedKeys.set(identity.pubKeyZ32, Date.now());
25291
- this.transport.publish(identity, emptyLinkRecords()).catch(() => {});
26747
+ this.warmKey(identity);
25292
26748
  }
25293
26749
  this.spareTimer = setTimeout(() => {
25294
26750
  this.spareTimer = null;
@@ -25591,6 +27047,7 @@ var GhostlyNode = class GhostlyNode {
25591
27047
  * (a group on hubs dropped it) and is no longer in the group, or a group I left or forgot, replaces it.
25592
27048
  */
25593
27049
  groupMembersChanged(groupId) {
27050
+ this.quietOutsideEdges(groupId);
25594
27051
  const holders = this.settings.wakeHeldBy?.[groupId];
25595
27052
  if (!holders?.length) return;
25596
27053
  const members = this.membership(groupId)?.members;
@@ -25646,7 +27103,7 @@ var GhostlyNode = class GhostlyNode {
25646
27103
  async shareGroupWake(linkId) {
25647
27104
  const live = this.links.get(linkId);
25648
27105
  const own = this.settings.wake;
25649
- if (!live?.link?.groupsSupport || !this.meshEdge(live.stored)) return;
27106
+ if (!live?.link?.groupsSupport || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
25650
27107
  if (this.settings.wakeMutedGroups?.includes(live.stored.group)) {
25651
27108
  this.sendGroupWake(live, null);
25652
27109
  return;
@@ -25670,7 +27127,7 @@ var GhostlyNode = class GhostlyNode {
25670
27127
  /** A `group-wake` frame on an edge: the member it is pinned to shares how to wake it, or says to forget it. */
25671
27128
  receiveGroupWake(linkId, frame) {
25672
27129
  const live = this.links.get(linkId);
25673
- if (!live || !this.meshEdge(live.stored)) return;
27130
+ if (!live || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
25674
27131
  let window = this.groupWakeReceived.get(linkId);
25675
27132
  if (!window) this.groupWakeReceived.set(linkId, window = new RateWindow(6, 6e4));
25676
27133
  if (!window.take()) return;
@@ -25689,7 +27146,7 @@ var GhostlyNode = class GhostlyNode {
25689
27146
  */
25690
27147
  wakeMentioned(groupId, text, mentions) {
25691
27148
  if (!mentions.length || !this.settings.online || this.groups.isCommunityGroup(groupId)) return;
25692
- const edges = this.groupEdges(groupId);
27149
+ const edges = this.memberEdges(groupId);
25693
27150
  const wakes = groupWakes({
25694
27151
  group: groupId,
25695
27152
  mentions,
@@ -26338,6 +27795,7 @@ var GhostlyNode = class GhostlyNode {
26338
27795
  outboxFor(linkId) {
26339
27796
  let outbox = this.outboxes.get(linkId);
26340
27797
  if (!outbox) {
27798
+ const chat = this.links.get(linkId)?.stored;
26341
27799
  outbox = new Outbox({
26342
27800
  read: () => db.getMessages(linkId),
26343
27801
  update: async (id, delivery, error, extra) => {
@@ -26367,10 +27825,12 @@ var GhostlyNode = class GhostlyNode {
26367
27825
  }, { resender: {
26368
27826
  ready: (message) => {
26369
27827
  const live = this.links.get(linkId), link = live?.link;
27828
+ if (message.file && !message.wireId) return !!link?.isDataLinkOpen && link.textDelivery === "stream";
26370
27829
  if (message.via === "pkarr" && !GhostlyNode.dhtByChoice(live) && link?.textDelivery !== "stream") return false;
26371
27830
  return !!link?.canSendText && !!message.wireId && !link.validateText(message.text, message.timestamp, message.wireId, GhostlyNode.wireReply(message));
26372
27831
  },
26373
27832
  requeueExpired: () => !GhostlyNode.dhtByChoice(this.links.get(linkId)),
27833
+ ...!!chat?.profile && !chat.group && { sendFile: (message) => this.sendWaitingFile(linkId, message) },
26374
27834
  via: (message) => {
26375
27835
  const delivery = this.links.get(linkId)?.link?.textDelivery;
26376
27836
  return delivery === "dht" ? "pkarr" : delivery === "stream" ? "datalink" : message.via;
@@ -26745,12 +28205,19 @@ var GhostlyNode = class GhostlyNode {
26745
28205
  */
26746
28206
  transferFile(live, file, wireId, timestamp, fail, reply, forwarded) {
26747
28207
  const { link } = live;
26748
- if (!link) return fail("You are offline");
28208
+ if (!link) {
28209
+ fail("You are offline");
28210
+ return Promise.resolve();
28211
+ }
26749
28212
  this.transfers.set(file.id, {
26750
28213
  state: "transferring",
26751
28214
  transferred: 0,
26752
28215
  size: file.size
26753
28216
  });
28217
+ let said = () => {};
28218
+ const offered = new Promise((resolve) => {
28219
+ said = resolve;
28220
+ });
26754
28221
  (async () => {
26755
28222
  if (!reply || !forwarded) {
26756
28223
  const message = (await db.getMessages(live.stored.id)).find((m) => m.id === `me_${timestamp}`);
@@ -26782,6 +28249,7 @@ var GhostlyNode = class GhostlyNode {
26782
28249
  size: file.size
26783
28250
  });
26784
28251
  const source = streamStored(stored);
28252
+ said();
26785
28253
  await link.sendFile({
26786
28254
  id: wireId,
26787
28255
  name: file.name,
@@ -26794,7 +28262,8 @@ var GhostlyNode = class GhostlyNode {
26794
28262
  ...reply && { reply },
26795
28263
  ...forwarded && { forwarded }
26796
28264
  }, source);
26797
- })().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;
26798
28267
  }
26799
28268
  fileAction({ linkId, fileId, action }) {
26800
28269
  const live = this.links.get(linkId);
@@ -26823,13 +28292,39 @@ var GhostlyNode = class GhostlyNode {
26823
28292
  return link.supportsLargeFiles;
26824
28293
  }
26825
28294
  /**
26826
- * The chat is live: what waited for it goes now, in the order it was written. A file for an app that turns
26827
- * 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.
26828
28322
  */
26829
28323
  async sendWaiting(linkId) {
26830
28324
  const live = this.links.get(linkId);
26831
28325
  if (!live?.link?.isDataLinkOpen) return;
26832
- 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);
26833
28328
  if (waiting.some((m) => m.file) && !GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
26834
28329
  for (const message of waiting) if (message.file) {
26835
28330
  if (!GhostlyNode.takesFiles(live.link)) {
@@ -27491,13 +28986,14 @@ var GhostlyNode = class GhostlyNode {
27491
28986
  ...wireReaction(reaction),
27492
28987
  ...this.groups.signReaction(groupId, wireReaction(reaction))
27493
28988
  };
27494
- for (const edge of this.groupEdges(groupId).values()) try {
28989
+ for (const edge of this.memberEdges(groupId).values()) try {
27495
28990
  this.links.get(edge)?.link?.sendGroupFrame(frame);
27496
28991
  } catch {}
27497
28992
  return { error: null };
27498
28993
  }
27499
28994
  /** An edge of a private group opened: the member hears my latest reactions again, in case it missed them. */
27500
28995
  async resendGroupReactions(groupId, linkId) {
28996
+ if (this.outsideEdge(linkId)) return;
27501
28997
  const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
27502
28998
  for (const reaction of mine) try {
27503
28999
  this.links.get(linkId)?.link?.sendGroupFrame({
@@ -27557,7 +29053,7 @@ var GhostlyNode = class GhostlyNode {
27557
29053
  k: frame.k,
27558
29054
  sig: frame.sig
27559
29055
  });
27560
- 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);
27561
29057
  return { error: null };
27562
29058
  }
27563
29059
  const live = this.links.get(linkId);
@@ -27642,11 +29138,11 @@ var GhostlyNode = class GhostlyNode {
27642
29138
  sig: via.frame.sig
27643
29139
  }
27644
29140
  }) || !via) return;
27645
- 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);
27646
29142
  }
27647
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). */
27648
29144
  sendGroupPinFrame(groupId, member) {
27649
- 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);
27650
29146
  if (!pin?.k || !pin.sig || !edge || this.groups.isCommunityGroup(groupId) || !pinNumberHolds(pin.n)) return;
27651
29147
  const frame = {
27652
29148
  t: "group-pin",
@@ -27857,6 +29353,13 @@ var GhostlyNode = class GhostlyNode {
27857
29353
  if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
27858
29354
  if (network !== "mainnet" && network !== "testnet") throw new Error("Choose Mainnet or Testnet");
27859
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
+ }
27860
29363
  await this.refreshWallet();
27861
29364
  const card = type === "lightning" ? asked ?? this.lightnings[network].receivingId : void 0;
27862
29365
  const lightningName = card !== void 0 ? this.walletView.networks?.[network].lightnings?.find((c) => c.card === card)?.name : void 0;
@@ -27875,7 +29378,7 @@ var GhostlyNode = class GhostlyNode {
27875
29378
  try {
27876
29379
  if (type === "cashu") {
27877
29380
  const mints = this.networkMints(network);
27878
- await this.wallet.forget(mints);
29381
+ await this.wallet.forget(mints, acceptLoss === true);
27879
29382
  await this.updateSettings({ settings: { mints: this.settings.mints.filter((m) => !mints.includes(m)) } });
27880
29383
  } else if (type === "arkade") await this.arkWallets[network].remove();
27881
29384
  else if (type === "bark") await this.barkWallets[network].remove();
@@ -28555,8 +30058,32 @@ var GhostlyNode = class GhostlyNode {
28555
30058
  payRequest(params) {
28556
30059
  return this.desk.payRequest(params);
28557
30060
  }
28558
- reclaimPayment({ paymentId }) {
28559
- 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();
28560
30087
  }
28561
30088
  addService({ name, target }) {
28562
30089
  const cleanName = name.trim().slice(0, 48);
@@ -28692,9 +30219,11 @@ var GhostlyNode = class GhostlyNode {
28692
30219
  this.publicActivity.clear();
28693
30220
  }
28694
30221
  }
28695
- 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
+ }
28696
30225
  if (settings.nick !== void 0 || settings.shareProfile !== void 0) {
28697
- 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);
28698
30227
  this.capsChanged();
28699
30228
  }
28700
30229
  if (settings.hyperdhtRelay !== void 0) {
@@ -28782,7 +30311,7 @@ var GhostlyNode = class GhostlyNode {
28782
30311
  const identity = identityFromSeedB64(invite.seedB64);
28783
30312
  if (Date.now() - (this.warmedKeys.get(identity.pubKeyZ32) ?? 0) < SPARE_INVITE_MAX_AGE_MS) return;
28784
30313
  this.warmedKeys.set(identity.pubKeyZ32, Date.now());
28785
- this.transport.publish(identity, emptyLinkRecords()).catch(() => {});
30314
+ this.warmKey(identity);
28786
30315
  } catch {}
28787
30316
  }
28788
30317
  /** What this peer advertises. Chat, voice and video are what Ghostly always offered. */
@@ -28822,6 +30351,7 @@ var GhostlyNode = class GhostlyNode {
28822
30351
  this.links.delete(linkId);
28823
30352
  this.edgeRtcOff.delete(linkId);
28824
30353
  this.groupWakeReceived.delete(linkId);
30354
+ this.quietEdges.delete(linkId);
28825
30355
  this.groupNativeWaiting.delete(linkId);
28826
30356
  this.groupNativeRetryAt = 0;
28827
30357
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
@@ -28943,11 +30473,13 @@ var GhostlyNode = class GhostlyNode {
28943
30473
  const { stored } = live;
28944
30474
  const group = stored.group, peer = stored.groupPeer, entry = !!stored.groupEntry;
28945
30475
  const role = stored.groupEntry ?? "edge";
30476
+ const member = () => entry || this.groups.isCommunityGroup(group) || this.groups.inRoster(group, peer);
28946
30477
  let seen = false;
28947
30478
  const native = this.keepsGroupNative(stored);
28948
30479
  const resumeOn = native ? TRANSPORTS.find((t) => t !== "webrtc/1" && t in this.nativeFactories && !!stored.peerTransports?.includes(t)) : "webrtc/1";
28949
30480
  live.pairing = { status: "connecting" };
28950
30481
  live.link = new GhostLink({
30482
+ ownRecords: true,
28951
30483
  paymentMethods: entry ? {
28952
30484
  cashu: false,
28953
30485
  lightning: false,
@@ -28985,7 +30517,7 @@ var GhostlyNode = class GhostlyNode {
28985
30517
  trustOnFirstUse: false
28986
30518
  },
28987
30519
  transport: this.groupTransport,
28988
- nick: this.sharedNick,
30520
+ nick: !entry && this.quietEdge(stored) ? void 0 : this.sharedNick,
28989
30521
  pollIntervals: entry || this.groups.isCommunityGroup(group) ? this.pollIntervals : meshEdgeIntervals(this.pollIntervals, () => this.groups.meshSize(group)),
28990
30522
  autoConnect: true,
28991
30523
  ...entry ? {
@@ -29001,12 +30533,13 @@ var GhostlyNode = class GhostlyNode {
29001
30533
  getHostedHttpService: () => void 0,
29002
30534
  groupsSupport: true,
29003
30535
  events: {
29004
- 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),
29005
30537
  onGroupsSupport: (supported) => {
29006
30538
  if (supported) traceJoin(group, "link.ready", { role });
29007
30539
  if (!entry) this.noteEdgeLive(linkId, supported);
29008
30540
  if (supported) {
29009
30541
  if (entry) this.groups.entryReady(group, linkId, peer);
30542
+ else if (!member()) this.groups.edgeReady(group, peer, linkId);
29010
30543
  else {
29011
30544
  this.groups.edgeReady(group, peer, linkId);
29012
30545
  this.groupPayments.edgeReady(group, linkId).catch(() => {});
@@ -29031,10 +30564,10 @@ var GhostlyNode = class GhostlyNode {
29031
30564
  if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
29032
30565
  },
29033
30566
  ...entry ? {} : {
29034
- onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
29035
- onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
29036
- onPayment: (payment) => this.desk.onPayment(linkId, payment),
29037
- 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,
29038
30571
  onPeerNick: (nick) => this.groups.edgeNick(group, peer, nick ?? void 0)
29039
30572
  },
29040
30573
  onPresence: (presence) => {
@@ -29061,7 +30594,8 @@ var GhostlyNode = class GhostlyNode {
29061
30594
  onDataLinkState: (state) => {
29062
30595
  traceJoin(group, `link.${state}`, { role });
29063
30596
  if (state === "open" || live.dataLink === "open") live.lastSyncAt = Date.now();
29064
- 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(() => {});
29065
30599
  live.dataLink = state;
29066
30600
  if (entry && state !== "open" && live.entryDone) this.closeGroupLink(linkId).catch(() => {});
29067
30601
  if (state !== "open" && live.pairing?.status !== "error") live.pairing = { status: "connecting" };
@@ -29350,6 +30884,10 @@ var GhostlyNode = class GhostlyNode {
29350
30884
  this.emitState();
29351
30885
  },
29352
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
+ },
29353
30891
  onDataLinkState: (state) => {
29354
30892
  const was = live.dataLink;
29355
30893
  live.dataLink = state;
@@ -29487,7 +31025,8 @@ var GhostlyNode = class GhostlyNode {
29487
31025
  };
29488
31026
  },
29489
31027
  changed: (record) => this.peerCapsChanged(linkId, record),
29490
- published: () => live.link?.announceCapsRevision()
31028
+ published: () => live.link?.announceCapsRevision(),
31029
+ refused: (error) => traceLink(live.myPubKeyZ32, "caps-refused", { reason: error.reason })
29491
31030
  });
29492
31031
  if (stored.pairedPeerKey && nativeUp) {
29493
31032
  const late = setTimeout(() => caps.start(), STARTUP_QUIET_MS);
@@ -29578,7 +31117,7 @@ var GhostlyNode = class GhostlyNode {
29578
31117
  get nativeFactories() {
29579
31118
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
29580
31119
  return {
29581
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-cMlRWrmD.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
31120
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-VM9STKLd.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
29582
31121
  ...this.options.nativeTransports,
29583
31122
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
29584
31123
  };
@@ -29889,14 +31428,22 @@ var GhostlyNode = class GhostlyNode {
29889
31428
  * chat: the time its sender says is kept beside it (`sentAt`) and shown, and orders nothing (WISP 400, requirement
29890
31429
  * 10). A sender's clock ahead or behind then moves no message past another, and what I send next goes below what I
29891
31430
  * just received. A row of mine keeps its own time; one placed already (it has `sentAt`) is left as it is. A line
29892
- * of a group's history (a membership change, a rename) is not a message: it stays at its commit's time, as before.
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.
29893
31435
  */
29894
31436
  placed(message) {
29895
31437
  const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
29896
- if (message.event) return message.sender === "peer" ? {
29897
- ...message,
29898
- timestamp: receivedTimestamp(message.timestamp)
29899
- } : message;
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
+ }
29900
31447
  if (message.sender === "peer" && message.sentAt === void 0) {
29901
31448
  const sentAt = claimedTime(message.timestamp);
29902
31449
  message = {
@@ -29956,8 +31503,9 @@ var GhostlyNode = class GhostlyNode {
29956
31503
  };
29957
31504
  }
29958
31505
  if (!await db.addMessage(message)) return false;
31506
+ this.clockSample(message);
29959
31507
  const newest = this.newestAt.get(message.linkId);
29960
- if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, shownTime(message)));
31508
+ if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(message.linkId))));
29961
31509
  if (message.sender === "peer") this.messageFeedback("message", message, newest);
29962
31510
  if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
29963
31511
  const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
@@ -30042,7 +31590,7 @@ var GhostlyNode = class GhostlyNode {
30042
31590
  status: live.status,
30043
31591
  dataLink: live.dataLink,
30044
31592
  peerOnline: presence.online,
30045
- peerLastSeenAt: presence.lastPacketAt,
31593
+ peerLastSeenAt: presenceSeenAt(presence),
30046
31594
  peerServices: presence.services,
30047
31595
  lastMessageAt: live.lastMessageAt,
30048
31596
  peerAck: live.peerAck,
@@ -30131,6 +31679,7 @@ var EngineServer = class {
30131
31679
  }
30132
31680
  }, options);
30133
31681
  this.ready = this.node.start();
31682
+ this.ready.catch(() => {});
30134
31683
  }
30135
31684
  attach(client) {
30136
31685
  this.clients.add(client);
@@ -30156,6 +31705,11 @@ var EngineServer = class {
30156
31705
  });
30157
31706
  this.histories.get(client)?.add(link.id);
30158
31707
  });
31708
+ }, (error) => {
31709
+ this.post(client, {
31710
+ kind: "start-failed",
31711
+ failure: profileOpenFailure(error)
31712
+ });
30159
31713
  }).catch(() => {
30160
31714
  this.clients.delete(client);
30161
31715
  });
@@ -30263,4 +31817,4 @@ function signalKind(signal) {
30263
31817
  //#endregion
30264
31818
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
30265
31819
 
30266
- //# sourceMappingURL=server-Cajp8VLY.js.map
31820
+ //# sourceMappingURL=server-BnNXYzR9.js.map