@ghostlytools/cli 1.1.6 → 1.2.0

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 (37) hide show
  1. package/README.md +16 -11
  2. package/SKILL.md +2 -2
  3. package/dist/assets/arkade-3szZ1IpP.js +362 -0
  4. package/dist/assets/arkade-3szZ1IpP.js.map +1 -0
  5. package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
  6. package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
  7. package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
  8. package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
  9. package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
  10. package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
  11. package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
  12. package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
  13. package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
  14. package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
  15. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
  16. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
  17. package/dist/assets/{hyperdhtRelay-CnEkFl8R.js → hyperdhtRelay-DmTrpmba.js} +3 -3
  18. package/dist/assets/{hyperdhtRelay-CnEkFl8R.js.map → hyperdhtRelay-DmTrpmba.js.map} +1 -1
  19. package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
  20. package/dist/assets/idb-CTF6LF5B.js.map +1 -0
  21. package/dist/assets/{mainline-DQxDSWXm.js → mainline-BueFJx3f.js} +2 -2
  22. package/dist/assets/{mainline-DQxDSWXm.js.map → mainline-BueFJx3f.js.map} +1 -1
  23. package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
  24. package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
  25. package/dist/assets/{server-HMzdsyn_.js → server-BGXmSYx_.js} +1170 -1049
  26. package/dist/assets/server-BGXmSYx_.js.map +1 -0
  27. package/dist/assets/{src-DtxVo2Rm.js → src-BMUcYQ7b.js} +770 -244
  28. package/dist/assets/src-BMUcYQ7b.js.map +1 -0
  29. package/dist/assets/usdt-DklGBSeq.js +410 -0
  30. package/dist/assets/usdt-DklGBSeq.js.map +1 -0
  31. package/dist/ghostly.mjs +858 -137
  32. package/dist/ghostly.mjs.map +1 -1
  33. package/package.json +4 -4
  34. package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
  35. package/dist/assets/idb-BIdus0WX.js.map +0 -1
  36. package/dist/assets/server-HMzdsyn_.js.map +0 -1
  37. package/dist/assets/src-DtxVo2Rm.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
2
- import { $i as formatFileSize, $n as GROUP_FILE_LIMITS, $o as handoffEphemeral, $r as WAKE_CALL_INTERVAL_MS, $s as deviceKeyZ32, $t as SEPOLIA_TEST_USDT_FAUCET, A as entryParams, Aa as replySnippet, Ac as createRelayPayload, Ai as IdentityExchange, Ao as LEGACY_SERVICES, Ar as newerHead, As as enrollCancelReason, Ba as claimedTime, Bc as sanitizeAvatar, Bi as emptyProofLedger, Bn as engineText, Bo as HANDOFF_REQUEST, Br as transferStalled, Bs as deviceInviteJoinerParams, C as GROUP_READ_NOTE_COMMUNITY, Ca as reactionEmoji, Ci as DHT_TEXT_CAPABILITY, Cr as beaconRecords, Cs as signTombstone, Da as wireReaction, Dc as withRequestOptions, Dr as lobbyRecords, E as encodeCommunityLink, Ea as validReactionNumber, Ec as isDiscoveryBudgetError, Eo as parseCallSignal, Er as lobbyKeys, Fc as identityFromSeed, Fi as newIdentityBinding, Fo as HANDOFF_HAVE, Fr as readLobby, Fs as enrollProofFrame, G as GROUP_EDIT_TEXT_BYTES, Gc as toZ32, Gn as findEndpoint, Go as handoffAttemptFailed, Gr as typingActivity, Gs as encodeInviteCode, Gt as encodeDidDhtPacket, H as GroupSession, Ha as receivedTimestamp, Hc as fromBase64Url, Hi as TRANSPORTS, Hn as ENDPOINT, Ho as HANDOFF_VERIFIED, Hs as OwnInviteError, Ht as didDhtDocument, I as GroupWakeLimiter, Ia as checkStatusCard, Ic as identityFromSeedB64, Ii as receivedIdentityStatus, Io as HANDOFF_HELLO, Ir as shouldBeHub, Is as readEnrollGrant, J as mentionsMember, Ji as peekDhtMailbox, Jn as parseFedimintRequestPayload, Jo as handoffCancelFrame, Jr as mayPin, Js as DEVICES_CAPABILITY, Jt as ETHEREUM_USDT, K as carryMentions, Ka as appFingerprint, Kc as utf8Decode, Kn as isFederationId, Ko as handoffAttemptSucceeded, L as groupWakeFrame, La as sameButtonText, Lc as publicKeyFromZ32, Li as revokesIdentity, Lo as HANDOFF_OFFER, Lr as ChatFiles, Ls as readEnrollHello, M as knockRecords, Mi as emptyIdentityLedger, Mo as HANDOFF_ACK, Mr as rankHubs, Ms as enrollDoneFrame, N as mergeKnocks, Ni as identityRevocationValue, No as HANDOFF_CANCEL, Nr as readBeacon, Ns as enrollGrantFrame, O as decodeGroupEntryLink, Oa as pairedReplyAuthor, Oc as PacketTooLargeError, Oi as IDENTITY_REVOCATION_LABEL, Oo as emptyLinkRecords, Or as mergeBeacon, P as readKnocks, Pa as STATUS_CARD_LIMITS, Pc as createIdentity, Pi as identityStatement, Po as HANDOFF_DONE, Pr as readBeaconHead, Ps as enrollHelloFrame, Qn as pairedMessageFrame, Qo as handoffDigest, Qr as readPin, Qs as deviceEchoNonce, Qt as SEPOLIA_TEST_USDT, R as groupWakes, Ra as statusCardText, Rc as verify, Ri as lastSharedWithMe, Ro as HANDOFF_PAKE, Rr as FILE_LIMITS, Rs as verifyEnrollProof, S as CommunitySession, Sa as queueReaction, Sc as normalizeRelayUrl, Si as CapsExchange, Sr as beaconKeys, Ss as setUpdateFrame, T as decodeCommunityLink, Ta as readReaction, Tc as budgetRetryMs, Ti as dialDescriptors, Tr as freshHubs, Ua as shownTime, Uc as randomBytes, Ui as automaticTransport, Un as cashuRequestPayload, Uo as filePartName, Ur as TypingSender, Us as createChatInvite, Ut as didDhtFromPublicKey, V as GROUP_READ_NOTE, Va as heardTime, Vc as bytesEqual, Vi as validExternalKey, Vn as decodeBolt11, Vo as HANDOFF_TIMINGS, Vs as readDeviceInvite, Wc as toBase64Url, Wn as fedimintRequestPayload, Wo as handoffAttemptAllowed, Ws as decodeInviteCode, Wt as encodeDidDhtDeactivated, Xa as isAppRef, Xi as dhtFileId, Xn as INVITE_TAKEN, Xo as handoffContextProof, Xr as pinIsNewer, Xs as HANDOFF_CAPABILITY, Xt as ONCHAIN_PROVIDER, Y as groupName, Ya as isAppKey, Yi as DHT_FILE_CAPABILITY, Yn as GhostLink, Yo as handoffContext, Yr as nextPinNumber, Ys as ENROLL_CAPABILITY, Yt as EVM_TEST_CHAINS, Za as readAppRevocations, Zi as parseImageMeta, Zn as LIVENESS_PING_MS, Zo as handoffDataFrame, Zr as pinNumberHolds, Zs as deviceEchoFrame, Zt as PaymentPreflightError, _ as GroupTypingBoard, _a as COMMUNITY_REACTION_FRAME, _c as turnKeys, _i as HOLD_LIMITS, _o as decodeControl, _r as MESH_HUBS, _s as sealHandoffPiece, ai as wakeRequest, an as formatPaymentAmount, ao as rosterHas, as as handoffRetryAfter, b as sealGroupTyping, ba as ReactionWindow, bc as RelayTransport, bi as newMailbox, bo as RTC_CONFIG, br as pickMeshHubs, bs as readSetUpdate, c as APP_BUNDLE_LIMITS, cc as classifyTurnRead, ci as chatAppId, cn as SPARK_NETWORKS, co as DirectPathWatch, cs as newHandoffId, d as appViewOf, da as EDIT_RATE_WINDOW_MS, dc as TURN_MAX, di as BUTTONS_CAPABILITY, dn as sparkInvoiceDetails, do as presenceSeenAt, ds as readHandoffDone, ea as safeBlobType, ec as deviceLinkPairing, ei as WakeLimiter, en as TEST_USDT_FAUCET_AMOUNT, eo as canonicalJson, es as handoffHelloFrame, fa as RateWindow, fc as firstDeviceSetSecret, fi as EDIT_CAPABILITY, fn as isBitcoinAddress, fo as GhostlyHttpError, fs as readHandoffHave, ga as readForwarded, gc as signTurnRelease, gi as HOLD_DATED_BACK_MS, go as LIMITS, gr as readGroupSignal, gs as readHandoffVerified, gt as normalizeAtprotoHandle, h as readAppBundle, ha as forwardedAgain, hc as signTurnPacket, hi as HOLD_CAPABILITY, ho as webLocalFetch, hr as groupSignalFrame, hs as readHandoffTurnFrame, ht as isAtprotoDid, i as planAppUpdate, ic as webCryptoSigner, ii as relayRequest, in as assertWholeSats, io as MEMBER_KEY, ir as groupFileFallback, is as handoffReleaseFrame, j as knockIdentity, ja as BUTTON_ID, jo as serviceIdFromName, jr as pickHubs, js as enrollDigits, k as encodeGroupEntryLink, ka as readReply, ko as HTTP_SERVICE_PROTO, kr as mergeLobby, ks as enrollCancelFrame, l as APP_BUNDLE_MAGIC, la as parseVideoMeta, lc as turnPutSummary, li as isAppVersion, ln as SPARK_PROVIDER, ls as readHandoffAck, m as isAppUrl, ma as parseLinkPreview, mc as readTurnPacket, mi as UPGRADE_CAPABILITY, mn as isPubkyKey, mo as parseLocalTarget, ms as readHandoffPake, n as appUpdateDecision, nc as newDeviceSetSecret, ni as newWakeToken, nn as WALLET_NETWORKS, no as GROUP_MEMBER_CAP, ns as handoffOfferFrame, o as readAppStore, oa as fileMessageText, oc as TURN_RAISE_READ_TIMEOUT_MS, oi as checkPushEndpoint, on as validatePaymentTarget, oo as verifyCommitSignature, or as readGroupFileMeta, os as handoffStreamKey, pa as dhtEditId, pc as nextTurnPosition, pi as STATUS_CARD_CAPABILITY, po as formatLocalTarget, ps as readHandoffHello, q as parseCommunityEdit, qa as appRef, qc as utf8Encode, qi as DHT_MESSAGE_TTL, qn as parseCashuRequestPayload, qo as handoffBusyFrame, qr as PIN_LIMITS, qs as inviteOwnership, qt as signDidDhtPacket, r as checkAppBeforeRun, rc as seedSigner, ri as parseWakeFrame, rn as assertTokenUnits, rs as handoffPakeFrame, sa as parseVoiceMeta, sc as TURN_SETTLE_MS, si as vapidKeysMatch, sn as walletNetworkOf, so as edgeParams, ss as handoffVersions, t as appStoreDecision, tc as devicePingFrame, ti as deviceWakeRequest, tn as USDT_PUBLIC_RPC, ts as handoffManifestFrames, u as appDigest, ua as EDIT_BUFFER_MS, uc as TURN_LAST_SEQUENCE, ui as APPS_CAPABILITY, un as sparkAddressKind, uo as RELAY_POLL_INTERVALS, ur as CarriedTransport, us as readHandoffCancel, v as groupTypingEpoch, va as GROUP_REACTION_FRAME, vc as turnName, vi as HoldKeys, vo as encodeControl, vr as mayBeHub, w as communityHasFrame, wa as reactionIsNewer, wc as traceLink, wi as capsDescriptors, wr as doorHubs, ws as verifySetUpdate, x as COMMUNITY_LIMITS, xa as nextReactionNumber, xc as currentRelays, xi as readManifest, xr as COMMUNITY_TOPOLOGY, xs as setAckFrame, y as openGroupTyping, ya as REACTION_LIMITS, yc as DEFAULT_RELAYS, yi as HoldRefusedError, yr as meshHubs, z as parseGroupWakeFrame, za as arrivalKey, zi as PeerProofs, zn as engineError, zo as HANDOFF_PIECE_BYTES, zr as transferEnded, zs as createDeviceInvite } from "./src-DtxVo2Rm.js";
3
- import { a as clearProfileStores, c as fileStore, d as store, f as transact, i as STORES$1, l as openDb, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as wrap, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as profileOpenFailure } from "./idb-BIdus0WX.js";
4
- import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-3AQ2tJzW.js";
5
- import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as paymentWireId, B as networkLabel, C as breezDatabasesFor, Ct as storedGroupFiles, D as sourceKey, Dt as replyRef, E as ProviderSources, Et as receivedPairedReply, F as sealSeed, Ft as streamStored, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, Mt as readStored$1, N as intentRepository, Nt as removeStored, O as awayFrom, Ot as replyTo, P as newDeviceKey, Pt as storedSize, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as groupOfFile, T as lightningPaying, Tt as pairedWireReply, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as groupFileId, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, h as rpcHost, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, jt as FileAppender, k as refuseAway, kt as paymentAlias, l as createTiming, lt as SourceUnreachableError, m as networkReason, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as groupReply, x as breezDatabase, xt as groupFileText, y as isRecoveryPhrase, yt as GroupFileDesk, z as WrongNetworkError } from "../ghostly.mjs";
2
+ import { $a as readAppRevocations, $i as parseImageMeta, $n as engineError, $o as handoffContextProof, $r as pinNumberHolds, $s as HANDOFF_CAPABILITY, $t as PaymentPreflightError, A as decodeGroupEntryLink, Aa as pairedReplyAuthor, Ac as withRequestOptions, Ai as IDENTITY_REVOCATION_LABEL, Ar as mergeBeacon, B as groupWakes, Ba as statusCardText, Bc as publicKeyFromZ32, Bi as lastSharedWithMe, Bo as HANDOFF_OFFER, Br as FILE_LIMITS, Bs as readEnrollHello, C as COMMUNITY_LIMITS, Ca as nextReactionNumber, Cc as RelayTransport, Ci as readManifest, Co as RTC_CONFIG, Cr as COMMUNITY_TOPOLOGY, Cs as readSetUpdate, D as decodeCommunityLink, Da as readReaction, Dc as traceLink, Di as dialDescriptors, Dr as freshHubs, Ds as verifySetUpdate, E as communityHasFrame, Ea as reactionIsNewer, Ei as capsDescriptors, Er as doorHubs, Es as signTombstone, F as mergeKnocks, Fi as identityRevocationValue, Fo as HANDOFF_ACK, Fr as readBeacon, Fs as enrollDoneFrame, Ga as shownTime, Gc as fromBase64Url, Gi as automaticTransport, Gn as findEndpoint, Go as HANDOFF_VERIFIED, Gr as TypingSender, Gs as OwnInviteError, Gt as didDhtFromPublicKey, Ha as claimedTime, Hi as emptyProofLedger, Hn as ENDPOINT, Ho as HANDOFF_PIECE_BYTES, Hr as transferStalled, Hs as createDeviceInvite, I as readKnocks, Ia as STATUS_CARD_LIMITS, Ii as identityStatement, Io as HANDOFF_CANCEL, Ir as readBeaconHead, Is as enrollGrantFrame, J as carryMentions, Ja as appFingerprint, Jc as toZ32, Jn as parseFedimintRequestPayload, Jo as handoffAttemptFailed, Js as encodeInviteCode, Kc as randomBytes, Kn as isFederationId, Ko as filePartName, Ks as createChatInvite, Kt as encodeDidDhtDeactivated, Lc as createIdentity, Li as newIdentityBinding, Lo as HANDOFF_DONE, Lr as readLobby, Ls as enrollHelloFrame, M as entryParams, Ma as replySnippet, Mi as IdentityExchange, Mo as HTTP_SERVICE_PROTO, Mr as newerHead, Ms as enrollCancelFrame, N as knockIdentity, Na as BUTTON_ID, Nc as createRelayPayload, No as LEGACY_SERVICES, Nr as pickHubs, Ns as enrollCancelReason, O as encodeCommunityLink, Oa as validReactionNumber, Oc as budgetRetryMs, Or as lobbyKeys, P as knockRecords, Pi as emptyIdentityLedger, Po as serviceIdFromName, Pr as rankHubs, Ps as enrollDigits, Qa as isAppRef, Qi as dhtFileId, Qn as pairedMessageFrame, Qo as handoffContext, Qr as pinIsNewer, Qs as ENROLL_CAPABILITY, Qt as ONCHAIN_PROVIDER, R as GroupWakeLimiter, Ra as checkStatusCard, Rc as identityFromSeed, Ri as receivedIdentityStatus, Ro as HANDOFF_HAVE, Rr as shouldBeHub, Rs as enrollProofFrame, S as sealGroupTyping, Sa as ReactionWindow, Sc as DEFAULT_RELAYS, Si as newMailbox, Sr as pickMeshHubs, T as GROUP_READ_NOTE_COMMUNITY, Ta as reactionEmoji, Tc as normalizeRelayUrl, Ti as DHT_TEXT_CAPABILITY, Tr as beaconRecords, Ts as setUpdateFrame, U as GROUP_READ_NOTE, Ua as heardTime, Uc as sanitizeAvatar, Ui as validExternalKey, Un as cashuRequestPayload, Uo as HANDOFF_REQUEST, Us as deviceInviteJoinerParams, V as parseGroupWakeFrame, Va as arrivalKey, Vc as verify, Vi as PeerProofs, Vn as decodeBolt11, Vo as HANDOFF_PAKE, Vr as transferEnded, Vs as verifyEnrollProof, W as GroupSession, Wa as receivedTimestamp, Wc as bytesEqual, Wi as TRANSPORTS, Wn as fedimintRequestPayload, Wo as HANDOFF_TIMINGS, Ws as readDeviceInvite, Wt as didDhtDocument, X as mentionsMember, Xc as utf8Encode, Xi as peekDhtMailbox, Xn as INVITE_TAKEN, Xo as handoffBusyFrame, Xr as mayPin, Xs as inviteOwnership, Xt as ETHEREUM_USDT, Y as parseCommunityEdit, Ya as appRef, Yc as utf8Decode, Yi as DHT_MESSAGE_TTL, Yn as GhostLink, Yo as handoffAttemptSucceeded, Yr as PIN_LIMITS, Yt as signDidDhtPacket, Z as groupName, Za as isAppKey, Zi as DHT_FILE_CAPABILITY, Zn as LIVENESS_PING_MS, Zo as handoffCancelFrame, Zr as nextPinNumber, Zs as DEVICES_CAPABILITY, Zt as EVM_TEST_CHAINS, _ as readAppBundle, _a as forwardedAgain, _c as readTurnPacket, _i as HOLD_CAPABILITY, _o as parseLocalTarget, _r as groupSignalFrame, _s as readHandoffPake, _t as isAtprotoDid, a as planAppUpdate, ac as newDeviceSetSecret, ai as parseWakeFrame, an as assertTokenUnits, as as handoffOfferFrame, b as groupTypingEpoch, ba as GROUP_REACTION_FRAME, bc as turnKeys, bi as HoldKeys, bo as decodeControl, br as mayBeHub, bs as sealHandoffPiece, ca as fileMessageText, ci as checkPushEndpoint, cn as validatePaymentTarget, co as verifyCommitSignature, cr as readGroupFileMeta, cs as handoffRetryAfter, da as parseVideoMeta, dc as classifyTurnRead, di as isAppVersion, dn as SPARK_PROVIDER, do as DHT_POLL_INTERVALS, ds as newHandoffId, ec as deviceEchoFrame, ei as readPin, en as SEPOLIA_TEST_USDT, er as engineText, es as handoffDataFrame, f as appDigest, fa as EDIT_BUFFER_MS, fc as turnPutSummary, fi as APPS_CAPABILITY, fn as sparkAddressKind, fr as CarriedTransport, fs as readHandoffAck, g as isAppUrl, ga as parseLinkPreview, gc as nextTurnPosition, gi as UPGRADE_CAPABILITY, gn as isPubkyKey, go as formatLocalTarget, gs as readHandoffHello, ha as dhtEditId, hc as firstDeviceSetSecret, hi as STATUS_CARD_CAPABILITY, ho as GhostlyHttpError, hs as readHandoffHave, i as checkAppBeforeRun, ia as sanitizeMime, ic as devicePingFrame, ii as newWakeToken, in as WALLET_NETWORKS, io as GROUP_MEMBER_CAP, is as handoffManifestFrames, j as encodeGroupEntryLink, ja as readReply, jc as PacketTooLargeError, jo as emptyLinkRecords, jr as mergeLobby, ka as wireReaction, kc as isDiscoveryBudgetError, ko as parseCallSignal, kr as lobbyRecords, l as APP_BUNDLE_LIMITS, la as parseVoiceMeta, lc as TURN_RAISE_READ_TIMEOUT_MS, li as vapidKeysMatch, ln as walletNetworkOf, lo as edgeParams, ls as handoffStreamKey, ma as RateWindow, mc as TURN_MAX, mi as EDIT_CAPABILITY, mn as isBitcoinAddress, mo as presenceSeenAt, ms as readHandoffDone, n as appStoreDecision, na as safeBlobType, nc as deviceKeyZ32, ni as WakeLimiter, nn as TEST_USDT_FAUCET_AMOUNT, no as canonicalJson, ns as handoffEphemeral, oc as seedSigner, oi as relayRequest, on as assertWholeSats, oo as MEMBER_KEY, or as groupFileFallback, os as handoffPakeFrame, p as appViewOf, pa as EDIT_RATE_WINDOW_MS, pc as TURN_LAST_SEQUENCE, pi as BUTTONS_CAPABILITY, pn as sparkInvoiceDetails, po as RELAY_POLL_INTERVALS, ps as readHandoffCancel, q as GROUP_EDIT_TEXT_BYTES, qc as toBase64Url, qn as parseCashuRequestPayload, qo as handoffAttemptAllowed, qr as typingActivity, qs as decodeInviteCode, qt as encodeDidDhtPacket, r as appUpdateDecision, ra as sanitizeFileName, rc as deviceLinkPairing, ri as deviceWakeRequest, rn as USDT_PUBLIC_RPC, rs as handoffHelloFrame, s as readAppStore, sc as webCryptoSigner, si as wakeRequest, sn as formatPaymentAmount, so as rosterHas, ss as handoffReleaseFrame, t as appRevocationFor, ta as formatFileSize, tc as deviceEchoNonce, ti as WAKE_CALL_INTERVAL_MS, tr as GROUP_FILE_LIMITS, ts as handoffDigest, u as APP_BUNDLE_MAGIC, uc as TURN_SETTLE_MS, ui as chatAppId, un as SPARK_NETWORKS, uo as DirectPathWatch, us as handoffVersions, va as readForwarded, vc as signTurnPacket, vi as HOLD_DATED_BACK_MS, vo as webLocalFetch, vr as readGroupSignal, vs as readHandoffTurnFrame, vt as normalizeAtprotoHandle, w as CommunitySession, wa as queueReaction, wc as currentRelays, wi as CapsExchange, wr as beaconKeys, ws as setAckFrame, x as openGroupTyping, xa as REACTION_LIMITS, xc as turnName, xi as HoldRefusedError, xo as encodeControl, xr as meshHubs, y as GroupTypingBoard, ya as COMMUNITY_REACTION_FRAME, yc as signTurnRelease, yi as HOLD_LIMITS, yo as LIMITS, yr as MESH_HUBS, ys as readHandoffVerified, z as groupWakeFrame, za as sameButtonText, zc as identityFromSeedB64, zi as revokesIdentity, zo as HANDOFF_HELLO, zr as ChatFiles, zs as readEnrollGrant } from "./src-BMUcYQ7b.js";
3
+ import { a as clearProfileStores, c as fileStore, d as profileOpenFailure, f as store, i as STORES$1, l as newestWalletTx, m as wrap, n as APP_STORAGE_SCOPE_INDEX, o as databaseName, p as transact, r as CARD_INDEX, s as deleteDatabase, t as APP_STORAGE_CHAT_INDEX, u as openDb } from "./idb-CTF6LF5B.js";
4
+ import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-BcYZhNgF.js";
5
+ import { $ as BACKUP_MEDIA_TYPE, A as requireTurn, At as paymentWireId, B as networkLabel, C as breezDatabasesFor, Ct as storedGroupFiles, D as sourceKey, Dt as replyRef, E as ProviderSources, Et as receivedPairedReply, F as sealSeed, Ft as streamStored, G as BITCOIN_INVOICE_ON_TESTNET, H as cashuMint, I as unsealSeed, J as fakesLightning, K as DEFAULT_MINTS, L as ModeChanged, M as setSingleWriterGate, Mt as readStored$1, N as intentRepository, Nt as removeStored, O as awayFrom, Ot as replyTo, P as newDeviceKey, Pt as storedSize, Q as paysItsOwnInvoices, R as ModeGate, S as breezDatabaseInUse, St as groupOfFile, T as lightningPaying, Tt as pairedWireReply, V as CASHU_MINT_SOURCE, W as WALLET_TYPES, X as isWorthlessMint, Y as isTestMint, Z as mintNetwork, _ as LightningCards, _t as verifyIdentity, a as irohRelayProblem, at as reserveAdapterIds, b as normalizePhrase, bt as groupFileId, c as createFailure, ct as SourceConfigError, d as paymentNetwork, dt as networkMode, et as MAX_BACKUP_BYTES, f as paymentNetworksOf, ft as redact, g as CASHU_CARD, gt as boundedIdentityFetch, ht as setOwnDidSource, i as createIrohWebEndpoint, it as registeredOnchainProviders, j as setAwayWallets, jt as FileAppender, k as refuseAway, kt as paymentAlias, l as createTiming, lt as SourceUnreachableError, mt as resolveAtprotoDid, n as loadFedimintSdk, nt as onAdaptersChanged, o as WALLET_NAMES, ot as NothingSpentError, p as walletInstances, pt as assertPublicServer, q as TEST_MINT, r as DEFAULT_IROH_RELAYS, rt as registeredLightningProviders, s as assertConfirmedReal, st as PROVIDER_PLATFORMS, t as fedimintDatabase, tt as identityProviders, u as crossNetwork, ut as isNothingSpentError, v as cardsKey, vt as chosenResolver, w as dropBreezDatabase, wt as groupReply, x as breezDatabase, xt as groupFileText, y as isRecoveryPhrase, yt as GroupFileDesk, z as WrongNetworkError } from "../ghostly.mjs";
6
6
  import { n as traceJoin } from "./joinTrace-CO8mlhNO.js";
7
7
  import { hkdf } from "@noble/hashes/hkdf.js";
8
8
  import { sha256 } from "@noble/hashes/sha2.js";
@@ -14,12 +14,8 @@ import { wordlist } from "@scure/bip39/wordlists/english.js";
14
14
  import { decode, noteEncode, npubEncode } from "nostr-tools/nip19";
15
15
  import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
16
16
  import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
17
- import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
18
- import { Interface, Transaction, getAddress, keccak256 } from "ethers";
19
- import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
20
- import { tapLeafHash } from "@scure/btc-signer/payment.js";
21
- import { Amount, MeltChangeError, OutputData, Wallet as Wallet$1, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
22
- import { Transaction as Transaction$2 } from "@scure/btc-signer";
17
+ import { Amount, MeltChangeError, OutputData, Wallet, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
18
+ import { Address, OutScript, Transaction } from "@scure/btc-signer";
23
19
  import * as nip04 from "nostr-tools/nip04";
24
20
  import * as nip44 from "nostr-tools/nip44";
25
21
  /** Evidence older than this says nothing about the clock now. */
@@ -182,409 +178,9 @@ async function migrateWalletNetworks(now = Date.now()) {
182
178
  return report;
183
179
  }
184
180
  //#endregion
185
- //#region ../browser/src/engine/paymentAdapters/usdt.ts
186
- var faucet = new Interface(["function mint(address token,address to,uint256 amount) returns (uint256)"]);
187
- var erc20 = new Interface([
188
- "function decimals() view returns(uint8)",
189
- "function balanceOf(address) view returns(uint256)",
190
- "function transfer(address,uint256) returns(bool)",
191
- "event Transfer(address indexed from,address indexed to,uint256 value)"
192
- ]);
193
- var same$1 = (a, b) => a.toLowerCase() === b.toLowerCase();
194
- var CONFIRMATIONS = 2;
195
- /** The JSON-RPC methods that only read: asked again once when they fail (see `UsdtAdapter.rpc`). */
196
- var RETRIED_READS = /* @__PURE__ */ new Set([
197
- "eth_chainId",
198
- "eth_getCode",
199
- "eth_call",
200
- "eth_getBalance",
201
- "eth_blockNumber",
202
- "eth_getLogs",
203
- "eth_getTransactionCount",
204
- "eth_estimateGas",
205
- "eth_getBlockByNumber",
206
- "eth_maxPriorityFeePerGas",
207
- "eth_gasPrice",
208
- "eth_feeHistory",
209
- "eth_getTransactionByHash",
210
- "eth_getTransactionReceipt"
211
- ]);
212
- var RETRY_AFTER_MS = 1e3;
213
- /** Failures that would fail the same way again at once: not retried. */
214
- var FINAL = /* @__PURE__ */ new WeakSet();
215
- var timedOut = (error) => error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError");
216
- /**
217
- * The `provider` a request carries to the contact. Which RPC pays is the payer's own choice, so it names none of
218
- * this wallet's: a typed RPC URL may hold an API key (`/v3/<key>`), and every contact would read it. Older apps
219
- * require a URL here and pay only when it equals their own RPC, so it is the chain's public RPC (the default both
220
- * start with), and a local test chain's origin, without its path.
221
- */
222
- function wireProvider(config) {
223
- return config.network === "evm-local" ? new URL(config.provider).origin : USDT_PUBLIC_RPC[config.network];
224
- }
225
- /** WDK signs locally. Every network call uses the configured RPC, never a peer-supplied URL. */
226
- var UsdtAdapter = class UsdtAdapter {
227
- config;
228
- method = "usdt";
229
- manager;
230
- account;
231
- mnemonic = "";
232
- nextId = 0;
233
- queue = Promise.resolve();
234
- constructor(config) {
235
- this.config = config;
236
- }
237
- static async inspect(config) {
238
- if (config.network === "ethereum" ? config.chainId !== 1 || !same$1(config.token, "0xdAC17F958D2ee523a2206206994597C13D831ec7") : !(config.network in EVM_TEST_CHAINS) || config.chainId !== EVM_TEST_CHAINS[config.network]) throw new Error("Unsupported USDT network or contract");
239
- const token = getAddress(config.token);
240
- const url = new URL(config.provider);
241
- if (url.username || url.password || url.search || url.hash || url.protocol !== "https:" && !(url.protocol === "http:" && [
242
- "localhost",
243
- "127.0.0.1",
244
- "[::1]"
245
- ].includes(url.hostname))) throw new Error("Use HTTPS or a loopback test RPC");
246
- const adapter = new UsdtAdapter({
247
- ...config,
248
- token,
249
- decimals: 6,
250
- codeHash: ""
251
- });
252
- await adapter.chain();
253
- const code = await adapter.rpc("eth_getCode", [token, "latest"]);
254
- if (code === "0x") throw new Error("No token contract exists on this chain");
255
- const result = await adapter.rpc("eth_call", [{
256
- to: token,
257
- data: erc20.encodeFunctionData("decimals")
258
- }, "latest"]);
259
- const decimals = Number(erc20.decodeFunctionResult("decimals", result)[0]);
260
- if (!Number.isInteger(decimals) || decimals < 0 || decimals > 18 || config.chainId === 1 && decimals !== 6) throw new Error("Unexpected token decimals");
261
- return {
262
- ...config,
263
- token,
264
- decimals,
265
- codeHash: keccak256(code)
266
- };
267
- }
268
- static async connect(config, mnemonic) {
269
- const inspected = await UsdtAdapter.inspect(config);
270
- if (inspected.decimals !== config.decimals || inspected.codeHash !== config.codeHash) throw new Error("Token metadata changed; do not use this wallet configuration");
271
- const adapter = new UsdtAdapter(config);
272
- adapter.mnemonic = mnemonic;
273
- const seed = mnemonicToSeedSync(mnemonic);
274
- try {
275
- adapter.manager = new WalletManagerEvm(seed, {
276
- provider: { request: ({ method, params }) => adapter.rpc(method, params ?? []) },
277
- chainId: config.chainId
278
- });
279
- } finally {
280
- seed.fill(0);
281
- }
282
- adapter.account = await adapter.manager.getAccount(0);
283
- return adapter;
284
- }
285
- /**
286
- * A read that fails is asked again, once, a moment later: a public RPC drops a request now and then, and a chain that
287
- * just made a block can refuse a read of "latest" it no longer holds. Without it a network switch, a restore or New
288
- * gave up on the first such answer. Never a broadcast or anything else that writes, nor a request that did not answer
289
- * in time (the person would wait twice as long), nor one the server refused as such (4xx).
290
- */
291
- async rpc(method, params = []) {
292
- try {
293
- return await this.ask(method, params);
294
- } catch (error) {
295
- if (!RETRIED_READS.has(method) || FINAL.has(error)) throw error;
296
- await new Promise((resolve) => setTimeout(resolve, RETRY_AFTER_MS));
297
- return this.ask(method, params);
298
- }
299
- }
300
- async ask(method, params) {
301
- let response;
302
- try {
303
- response = await fetch(this.config.provider, {
304
- method: "POST",
305
- headers: { "Content-Type": "application/json" },
306
- body: JSON.stringify({
307
- jsonrpc: "2.0",
308
- id: ++this.nextId,
309
- method,
310
- params
311
- }),
312
- signal: AbortSignal.timeout(15e3)
313
- });
314
- } catch (error) {
315
- const reason = networkReason(error, rpcHost(this.config.provider));
316
- const thrown = reason ? Object.assign(new Error(reason), { cause: error }) : error;
317
- if (timedOut(error) && thrown && typeof thrown === "object") FINAL.add(thrown);
318
- throw thrown;
319
- }
320
- if (!response.ok) {
321
- const error = engineError("usdtRpcUnavailable");
322
- if (response.status !== 429 && response.status < 500) FINAL.add(error);
323
- throw error;
324
- }
325
- const result = await response.json();
326
- if (result.error || !Object.prototype.hasOwnProperty.call(result, "result")) throw new Error("USDT RPC rejected the operation");
327
- return result.result;
328
- }
329
- async chain() {
330
- if (BigInt(await this.rpc("eth_chainId")) !== BigInt(this.config.chainId)) throw new Error("RPC chain does not match this wallet");
331
- }
332
- async assertToken() {
333
- await this.chain();
334
- const code = await this.rpc("eth_getCode", [this.config.token, "latest"]);
335
- if (code === "0x" || keccak256(code) !== this.config.codeHash) throw new Error("Token contract changed");
336
- }
337
- /**
338
- * Sepolia only: asks Aave's public faucet for test USDT, to this wallet, paid with its own test ETH.
339
- * Everything is fixed here (chain, contract, token, amount, recipient): nothing comes from outside.
340
- */
341
- mintTestTokens() {
342
- return this.serial(async () => {
343
- if (this.config.chainId !== EVM_TEST_CHAINS.sepolia || !same$1(this.config.token, "0xaA8E23Fb1079EA71e0a56F48a2aA51851D8433D0")) throw new Error("Test USDT comes from a faucet on Sepolia only");
344
- await this.chain();
345
- const account = this.current(), from = await account.getAddress();
346
- const data = faucet.encodeFunctionData("mint", [
347
- SEPOLIA_TEST_USDT,
348
- from,
349
- BigInt(TEST_USDT_FAUCET_AMOUNT)
350
- ]);
351
- const nonce = Number(BigInt(await this.rpc("eth_getTransactionCount", [from, "pending"])));
352
- const estimate = BigInt(await this.rpc("eth_estimateGas", [{
353
- from,
354
- to: SEPOLIA_TEST_USDT_FAUCET,
355
- data,
356
- value: "0x0"
357
- }]));
358
- const block = await this.rpc("eth_getBlockByNumber", ["latest", false]);
359
- if (!block.baseFeePerGas) throw new Error("This wallet requires an EIP-1559 chain");
360
- const priority = BigInt(await this.rpc("eth_maxPriorityFeePerGas"));
361
- const maxFeePerGas = BigInt(block.baseFeePerGas) * 2n + priority, gasLimit = (estimate * 120n + 99n) / 100n;
362
- if (BigInt((await this.balances()).gasBalance) < gasLimit * maxFeePerGas) throw engineError("usdtNeedsSepoliaGas");
363
- const raw = await account.signTransaction({
364
- type: 2,
365
- to: SEPOLIA_TEST_USDT_FAUCET,
366
- data,
367
- value: 0n,
368
- chainId: this.config.chainId,
369
- nonce,
370
- gasLimit,
371
- maxFeePerGas,
372
- maxPriorityFeePerGas: priority
373
- });
374
- await account.sendTransaction(raw);
375
- return Transaction.from(raw).hash;
376
- });
377
- }
378
- current() {
379
- if (!this.account) throw new Error("Unlock the USDT wallet first");
380
- return this.account;
381
- }
382
- address() {
383
- return this.current().getAddress();
384
- }
385
- async balances() {
386
- await this.assertToken();
387
- const address = await this.address();
388
- const [token, gas] = await Promise.all([this.rpc("eth_call", [{
389
- to: this.config.token,
390
- data: erc20.encodeFunctionData("balanceOf", [address])
391
- }, "latest"]), this.rpc("eth_getBalance", [address, "pending"])]);
392
- return {
393
- balance: erc20.decodeFunctionResult("balanceOf", token)[0].toString(),
394
- gasBalance: BigInt(gas).toString()
395
- };
396
- }
397
- async target() {
398
- const now = Date.now();
399
- return {
400
- method: "usdt",
401
- network: this.config.network,
402
- provider: wireProvider(this.config),
403
- asset: this.config.chainId === 1 ? "USDT" : "TEST-USDT",
404
- unit: "token-base",
405
- address: await this.address(),
406
- chainId: this.config.chainId,
407
- token: this.config.token,
408
- decimals: this.config.decimals,
409
- issuedAt: now,
410
- expiresAt: now + 9e5
411
- };
412
- }
413
- /** The request is for this wallet's token on its chain. Its `provider` is not compared: this wallet's own RPC pays. */
414
- matches(target) {
415
- validatePaymentTarget(target);
416
- if (target.method !== "usdt" || target.network !== this.config.network || target.chainId !== this.config.chainId || !same$1(target.token, this.config.token) || target.decimals !== this.config.decimals) throw new Error("Request does not match the configured token and network");
417
- }
418
- prepare(target, amount, feeCap) {
419
- return this.serial(async () => {
420
- this.matches(target);
421
- await this.assertToken();
422
- const from = await this.address(), to = this.config.token;
423
- if (same$1(target.address, from) || same$1(target.address, to)) throw new Error("Choose a different recipient address");
424
- const { balance, gasBalance } = await this.balances();
425
- if (BigInt(balance) < BigInt(amount)) throw engineError("usdtNotEnoughTokens");
426
- const data = erc20.encodeFunctionData("transfer", [getAddress(target.address), BigInt(amount)]);
427
- const nonce = Number(BigInt(await this.rpc("eth_getTransactionCount", [from, "pending"])));
428
- if (!Number.isSafeInteger(nonce)) throw new Error("Invalid account nonce");
429
- const estimate = BigInt(await this.rpc("eth_estimateGas", [{
430
- from,
431
- to,
432
- data,
433
- value: "0x0"
434
- }]));
435
- const block = await this.rpc("eth_getBlockByNumber", ["latest", false]);
436
- if (!block.baseFeePerGas) throw new Error("This wallet requires an EIP-1559 chain");
437
- const priority = BigInt(await this.rpc("eth_maxPriorityFeePerGas"));
438
- const maxFee = BigInt(block.baseFeePerGas) * 2n + priority;
439
- const gasLimit = (estimate * 120n + 99n) / 100n;
440
- const maximum = gasLimit * maxFee;
441
- if (maximum > BigInt(feeCap) || maximum > BigInt(Number.MAX_SAFE_INTEGER)) throw engineError("usdtGasAboveLimit");
442
- if (BigInt(gasBalance) < maximum) throw engineError("usdtNotEnoughGas");
443
- const prepared = {
444
- from,
445
- to,
446
- data,
447
- value: "0",
448
- chainId: this.config.chainId,
449
- nonce,
450
- gasLimit: gasLimit.toString(),
451
- maxFeePerGas: maxFee.toString(),
452
- maxPriorityFeePerGas: priority.toString()
453
- };
454
- return {
455
- fee: Number(maximum),
456
- prepared,
457
- evm: {
458
- from,
459
- nonce,
460
- gasLimit: prepared.gasLimit,
461
- maxFeePerGas: prepared.maxFeePerGas,
462
- maxPriorityFeePerGas: prepared.maxPriorityFeePerGas,
463
- confirmations: CONFIRMATIONS
464
- }
465
- };
466
- });
467
- }
468
- verifyPrepared(review, tx) {
469
- if (tx.chainId !== this.config.chainId || !same$1(tx.to, this.config.token) || tx.value !== "0" || tx.data !== erc20.encodeFunctionData("transfer", [getAddress(review.address), BigInt(review.amount)]) || BigInt(tx.gasLimit) * BigInt(tx.maxFeePerGas) !== BigInt(review.fee) || review.fee > review.feeCap || tx.nonce !== review.evm?.nonce || !same$1(tx.from, review.evm?.from ?? "")) throw new Error("Transaction no longer matches the approved review");
470
- }
471
- verifySigned(raw, saved) {
472
- const tx = Transaction.from(raw);
473
- if (!tx.hash || !tx.from || !same$1(tx.from, saved.from) || tx.chainId !== BigInt(saved.chainId) || !tx.to || !same$1(tx.to, saved.to) || tx.data !== saved.data || tx.value !== 0n || tx.nonce !== saved.nonce || tx.gasLimit !== BigInt(saved.gasLimit) || tx.maxFeePerGas !== BigInt(saved.maxFeePerGas) || tx.maxPriorityFeePerGas !== BigInt(saved.maxPriorityFeePerGas)) throw new Error("Signature does not match the approved transaction");
474
- return tx.hash;
475
- }
476
- execute(review, prepared, persist) {
477
- return this.serial(async () => {
478
- let account;
479
- try {
480
- this.matches(review);
481
- this.verifyPrepared(review, prepared);
482
- await this.assertToken();
483
- account = this.current();
484
- if (!same$1(await account.getAddress(), prepared.from)) throw new Error("Wrong wallet");
485
- if (Number(BigInt(await this.rpc("eth_getTransactionCount", [prepared.from, "pending"]))) !== prepared.nonce) throw new Error("Account nonce changed. Create a new review");
486
- const balances = await this.balances();
487
- if (BigInt(balances.balance) < BigInt(review.amount) || BigInt(balances.gasBalance) < BigInt(review.fee)) throw new Error("Token or gas balance changed. Create a new review");
488
- if (!persist) throw new Error("Durable payment journal required");
489
- } catch (error) {
490
- throw new PaymentPreflightError(error instanceof Error ? error.message : "Could not validate payment");
491
- }
492
- const raw = await account.signTransaction({
493
- type: 2,
494
- to: prepared.to,
495
- data: prepared.data,
496
- value: 0n,
497
- chainId: prepared.chainId,
498
- nonce: prepared.nonce,
499
- gasLimit: BigInt(prepared.gasLimit),
500
- maxFeePerGas: BigInt(prepared.maxFeePerGas),
501
- maxPriorityFeePerGas: BigInt(prepared.maxPriorityFeePerGas)
502
- });
503
- prepared.hash = this.verifySigned(raw, prepared);
504
- prepared.signed = await sealSeed(raw, this.mnemonic);
505
- await persist();
506
- await account.sendTransaction(raw);
507
- return this.receipt(prepared.hash, review, prepared.from);
508
- });
509
- }
510
- reconcile(review, prepared) {
511
- return this.serial(async () => {
512
- await this.assertToken();
513
- this.verifyPrepared(review, prepared);
514
- if (!prepared.signed || !prepared.hash) return {
515
- settled: false,
516
- failed: true,
517
- error: "No signed transaction was submitted. Create a new review."
518
- };
519
- const result = await this.receipt(prepared.hash, review, prepared.from);
520
- if (result.settled || result.failed || result.pending) return result;
521
- if (Number(BigInt(await this.rpc("eth_getTransactionCount", [prepared.from, "pending"]))) > prepared.nonce) return result;
522
- const raw = await unsealSeed(prepared.signed, this.mnemonic);
523
- if (this.verifySigned(raw, prepared) !== prepared.hash || !same$1(await this.address(), prepared.from)) throw new Error("Recovery wallet mismatch");
524
- await this.current().sendTransaction(raw);
525
- return this.receipt(prepared.hash, review, prepared.from);
526
- });
527
- }
528
- async receipt(hash, target, expectedFrom) {
529
- if (!/^0x[0-9a-fA-F]{64}$/.test(hash) || target.chainId !== this.config.chainId || !same$1(target.token ?? "", this.config.token) || target.decimals !== this.config.decimals) throw new Error("Invalid receipt target");
530
- await this.assertToken();
531
- const tx = await this.rpc("eth_getTransactionByHash", [hash]);
532
- if (!tx) return {
533
- txid: hash,
534
- settled: false
535
- };
536
- if (!same$1(tx.to, this.config.token) || expectedFrom && !same$1(tx.from, expectedFrom) || BigInt(tx.value) !== 0n || tx.input !== erc20.encodeFunctionData("transfer", [getAddress(target.address), BigInt(target.amount)])) throw new Error("Transaction is not the requested token transfer");
537
- const receipt = await this.rpc("eth_getTransactionReceipt", [hash]);
538
- if (!receipt) return {
539
- txid: hash,
540
- settled: false,
541
- pending: true
542
- };
543
- const block = await this.rpc("eth_getBlockByNumber", [receipt.blockNumber, false]);
544
- if (!block || block.hash !== receipt.blockHash || BigInt(block.timestamp) * 1000n < BigInt((target.issuedAt ?? 0) - 1e3)) return {
545
- txid: hash,
546
- settled: false
547
- };
548
- if (BigInt(await this.rpc("eth_blockNumber")) - BigInt(receipt.blockNumber) + 1n < BigInt(CONFIRMATIONS)) return {
549
- txid: hash,
550
- settled: false,
551
- pending: true
552
- };
553
- if (BigInt(receipt.status) !== 1n) return {
554
- txid: hash,
555
- settled: false,
556
- failed: true,
557
- error: "Transaction reverted. Tokens were not sent; gas was spent."
558
- };
559
- if (!receipt.logs.some((log) => {
560
- if (!same$1(log.address, this.config.token)) return false;
561
- try {
562
- const event = erc20.parseLog(log);
563
- return event?.name === "Transfer" && same$1(event.args[0], tx.from) && same$1(event.args[1], target.address) && event.args[2] === BigInt(target.amount);
564
- } catch {
565
- return false;
566
- }
567
- })) throw new Error("Receipt has no matching token Transfer");
568
- return {
569
- txid: hash,
570
- settled: true
571
- };
572
- }
573
- async dispose() {
574
- await this.queue.catch(() => {});
575
- this.account = void 0;
576
- this.manager?.dispose();
577
- this.manager = void 0;
578
- this.mnemonic = "";
579
- }
580
- serial(work) {
581
- const operation = this.queue.then(work, work);
582
- this.queue = operation.catch(() => {});
583
- return operation;
584
- }
585
- };
586
- //#endregion
587
181
  //#region ../browser/src/engine/paymentAdapters/usdtWallet.ts
182
+ /** The Ethereum libraries, a large part of the app: loaded once a USDT wallet is made, opened or restored, not with the engine. */
183
+ var loadUsdt = () => import("./usdt-DklGBSeq.js");
588
184
  /** Every new profile starts with this wallet: an address to receive on, nothing to set up. */
589
185
  var DEFAULT_USDT = {
590
186
  network: "ethereum",
@@ -707,6 +303,7 @@ var UsdtWallet = class {
707
303
  if (!validateMnemonic(mnemonic, wordlist)) throw new Error("Invalid recovery phrase");
708
304
  const deviceKey = params.password ? void 0 : newDeviceKey();
709
305
  const seed = await sealSeed(mnemonic, params.password ?? deviceKey);
306
+ const { UsdtAdapter } = await loadUsdt();
710
307
  const config = await this.gate.within(UsdtAdapter.inspect({
711
308
  network: params.network,
712
309
  chainId: params.network === "ethereum" ? 1 : EVM_TEST_CHAINS[params.network],
@@ -755,6 +352,7 @@ var UsdtWallet = class {
755
352
  if (!key) throw new Error("Enter the USDT wallet password");
756
353
  const epoch = ++this.epoch;
757
354
  const mnemonic = await unsealSeed(this.saved.seed, key);
355
+ const { UsdtAdapter } = await loadUsdt();
758
356
  const adapter = await this.gate.within(UsdtAdapter.connect(this.saved.config, mnemonic), (a) => a.dispose());
759
357
  if (epoch !== this.epoch) {
760
358
  await adapter.dispose();
@@ -872,7 +470,7 @@ var UsdtWallet = class {
872
470
  const payload = JSON.parse(await unsealSeed(envelope.vault, password));
873
471
  if (payload.format !== "ghostly-usdt" || payload.version !== 1 || !validateMnemonic(payload.mnemonic, wordlist) || !Array.isArray(payload.intents)) throw new Error("Invalid USDT backup");
874
472
  if (usdtMode(payload.config.network) !== this.network) throw new WrongNetworkError(usdtMode(payload.config.network), `This backup is a ${networkLabel(usdtMode(payload.config.network))} USDT wallet`);
875
- const config = await UsdtAdapter.inspect(payload.config);
473
+ const config = await (await loadUsdt()).UsdtAdapter.inspect(payload.config);
876
474
  if (config.codeHash !== payload.config.codeHash || config.decimals !== payload.config.decimals || payload.intents.some((i) => i.review.method !== "usdt" || i.review.chainId !== config.chainId || i.review.token?.toLowerCase() !== config.token.toLowerCase())) throw new Error("Backup token or network mismatch");
877
475
  const deviceKey = newDeviceKey();
878
476
  const saved = {
@@ -946,362 +544,6 @@ function iceServerProblem(server) {
946
544
  return null;
947
545
  }
948
546
  //#endregion
949
- //#region ../browser/src/engine/paymentAdapters/arkade.ts
950
- var unbase64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
951
- var base64$1 = (bytes) => btoa(Array.from(bytes, (b) => String.fromCharCode(b)).join(""));
952
- var hex$10 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
953
- var PreparedOnly = class extends Error {};
954
- var ARK_NETWORKS = [
955
- "bitcoin",
956
- "mutinynet",
957
- "signet",
958
- "regtest"
959
- ];
960
- /** Payments go through review and approval; only the SDK's own settlement (renewal, onboarding) reaches the provider directly. */
961
- var PreviewProvider = class extends RestArkProvider {
962
- authorizedFinalize;
963
- captured;
964
- take() {
965
- return this.captured;
966
- }
967
- async submitTx(signedTx, checkpoints) {
968
- this.captured = {
969
- signedTx,
970
- checkpoints
971
- };
972
- throw new PreparedOnly();
973
- }
974
- async finalizeTx(txid, checkpoints) {
975
- if (this.authorizedFinalize?.txid !== txid) throw new Error("Preview cannot finalize an unapproved payment");
976
- await this.authorizedFinalize.run(checkpoints);
977
- }
978
- };
979
- var ArkadeAdapter = class ArkadeAdapter {
980
- config;
981
- identity;
982
- wallet;
983
- preview;
984
- provider;
985
- indexer;
986
- repository;
987
- method = "arkade";
988
- pending = Promise.resolve();
989
- serial(run) {
990
- const next = this.pending.then(run, run);
991
- this.pending = next.catch(() => {});
992
- return next;
993
- }
994
- prepare(target, amount, feeCap) {
995
- return this.serial(() => this.prepareOperation(target, amount, feeCap));
996
- }
997
- execute(review, prepared, persist) {
998
- return this.serial(() => this.executeOperation(review, prepared, persist));
999
- }
1000
- reconcile(review, prepared, persist) {
1001
- return this.serial(() => this.reconcileOperation(review, prepared, persist));
1002
- }
1003
- constructor(config, identity, wallet, preview, provider, indexer, repository) {
1004
- this.config = config;
1005
- this.identity = identity;
1006
- this.wallet = wallet;
1007
- this.preview = preview;
1008
- this.provider = provider;
1009
- this.indexer = indexer;
1010
- this.repository = repository;
1011
- }
1012
- static async connect(config, mnemonic, storage) {
1013
- if (!ARK_NETWORKS.includes(config.network)) throw new Error("Unsupported Ark network");
1014
- validatePaymentTarget({
1015
- method: "arkade",
1016
- network: config.network,
1017
- provider: config.provider,
1018
- asset: "BTC",
1019
- unit: "sat",
1020
- address: "configuration",
1021
- expiresAt: Date.now() + 6e4
1022
- });
1023
- validatePaymentTarget({
1024
- method: "arkade",
1025
- network: config.network,
1026
- provider: config.explorer,
1027
- asset: "BTC",
1028
- unit: "sat",
1029
- address: "configuration",
1030
- expiresAt: Date.now() + 6e4
1031
- });
1032
- const provider = new RestArkProvider(config.provider);
1033
- const info = await provider.getInfo();
1034
- if (info.network !== config.network || info.signerPubkey !== config.serverKey) throw new Error("Ark provider network or signing key changed. Review the wallet configuration.");
1035
- const identity = MnemonicIdentity.fromMnemonic(mnemonic, { isMainnet: config.network === "bitcoin" });
1036
- const preview = new PreviewProvider(config.provider);
1037
- const indexer = new RestIndexerProvider(config.provider);
1038
- const repositories = {
1039
- ...storage,
1040
- walletRepository: storage?.walletRepository ?? new IndexedDBWalletRepository(`ghostly-ark-${config.walletId}`),
1041
- contractRepository: storage?.contractRepository ?? new IndexedDBContractRepository(`ghostly-ark-${config.walletId}`)
1042
- };
1043
- const wallet = await Wallet.create({
1044
- identity,
1045
- arkProvider: preview,
1046
- indexerProvider: indexer,
1047
- onchainProvider: new EsploraProvider(config.explorer),
1048
- settlementConfig: config.network === "regtest" ? false : { boardingUtxoSweep: true },
1049
- walletMode: "hd",
1050
- storage: repositories
1051
- });
1052
- return new ArkadeAdapter(config, identity, wallet, preview, provider, indexer, repositories.walletRepository);
1053
- }
1054
- async address() {
1055
- return this.wallet.getAddress();
1056
- }
1057
- requestAddress() {
1058
- return this.serial(async () => (await this.wallet.getNewAddresses({
1059
- types: ["default"],
1060
- forceNew: true
1061
- }))[0].address);
1062
- }
1063
- async balance() {
1064
- return (await this.wallet.getBalance()).available;
1065
- }
1066
- /**
1067
- * Sats whose batch expired before they were renewed, in three kinds, as a person can act on them:
1068
- * - `sweeping`: past their expiry, but the server has not swept their batch yet (with any swept ones: none can be
1069
- * recovered meanwhile). The SDK (0.4.76) counts them as
1070
- * recoverable already, but it settles an unswept coin without the forfeit the server still asks for (arkd wants
1071
- * one until the sweep): the batch fails ("missing forfeit transactions") and the server bans the coin for a while.
1072
- * The sweep waits for the chain's time to pass the expiry: an hour or more on a real network, the expiry's blocks
1073
- * on regtest.
1074
- * - `recoverable`: swept, and a recovery takes them now.
1075
- * - `small`: swept, but together under the server's dust limit: a recovery makes one new coin and the server makes
1076
- * none that small, so `recoverVtxos` answers "No recoverable VTXOs found". They wait for more coins to expire.
1077
- */
1078
- async expired() {
1079
- const now = { timestamp: /* @__PURE__ */ new Date() };
1080
- let swept = 0, sweeping = 0;
1081
- for (const vtxo of await this.wallet.getVtxos({
1082
- withRecoverable: true,
1083
- withUnrolled: false
1084
- })) if (isRecoverable(vtxo)) swept += vtxo.value;
1085
- else if (!vtxo.isSwept && canRecoverOnchain(vtxo, now)) sweeping += vtxo.value;
1086
- const takes = !sweeping && swept > 0 && (await (await this.wallet.getVtxoManager()).getRecoverableBalance()).recoverable >= this.wallet.dustAmount;
1087
- return sweeping ? {
1088
- recoverable: 0,
1089
- sweeping: sweeping + swept,
1090
- small: 0
1091
- } : {
1092
- recoverable: takes ? swept : 0,
1093
- sweeping: 0,
1094
- small: takes ? 0 : swept
1095
- };
1096
- }
1097
- /**
1098
- * When the first coin still spendable expires (ms), or undefined with none: a coin left alone past it is swept by the
1099
- * server and comes back only through a recovery. An expiry in blocks is read against the explorer's tip, at about ten
1100
- * minutes a block.
1101
- */
1102
- async expiresAt() {
1103
- let at, height;
1104
- for (const vtxo of await this.wallet.getVtxos({
1105
- withRecoverable: false,
1106
- withUnrolled: false
1107
- })) {
1108
- if (vtxo.isSwept || vtxo.isSpent) continue;
1109
- if (vtxo.expiresAt instanceof Date) {
1110
- const ms = vtxo.expiresAt.getTime();
1111
- if (Number.isFinite(ms)) at = Math.min(at ?? ms, ms);
1112
- } else if (typeof vtxo.expiresAtHeight === "number") height = Math.min(height ?? vtxo.expiresAtHeight, vtxo.expiresAtHeight);
1113
- }
1114
- if (height !== void 0) {
1115
- const tip = (await new EsploraProvider(this.config.explorer).getChainTip()).height;
1116
- const ms = Date.now() + Math.max(0, height - tip) * 6e5;
1117
- at = Math.min(at ?? ms, ms);
1118
- }
1119
- return at;
1120
- }
1121
- /**
1122
- * Moves swept outputs back into the balance, through the server's next batch. Refused while any expired coin is
1123
- * not swept yet (`recoverVtxos` would name it too, and that batch fails for all of them), and when what is swept
1124
- * is too little for a batch.
1125
- */
1126
- recover() {
1127
- return this.serial(async () => {
1128
- const { recoverable, sweeping, small } = await this.expired();
1129
- if (sweeping) throw new Error("Expired coins can be recovered once the Ark server has swept their batch. Try again later.");
1130
- if (small) throw new Error("Too few expired sats to recover on their own. They are recovered with the next coins that expire.");
1131
- if (!recoverable) throw new Error("Nothing to recover");
1132
- return (await this.wallet.getVtxoManager()).recoverVtxos();
1133
- });
1134
- }
1135
- /** On-chain deposits land here; the SDK's settlement moves them into Ark once confirmed. */
1136
- boardingAddress() {
1137
- return this.wallet.getBoardingAddress();
1138
- }
1139
- /** Sats sent on-chain to the boarding address that are not in Ark yet. */
1140
- async incoming() {
1141
- return (await this.wallet.getBalance()).boarding.total;
1142
- }
1143
- async dispose() {
1144
- await this.wallet.dispose();
1145
- }
1146
- async prepareOperation(target, amount, feeCap) {
1147
- validatePaymentTarget(target);
1148
- assertWholeSats(amount);
1149
- this.checkTarget(target);
1150
- const info = await this.provider.getInfo();
1151
- if (info.network !== this.config.network || info.signerPubkey !== this.config.serverKey) throw new Error("Ark provider configuration changed");
1152
- if (await this.balance() < amount) throw new Error("Insufficient Ark balance");
1153
- this.preview.captured = void 0;
1154
- try {
1155
- await this.wallet.send({
1156
- address: target.address,
1157
- amount
1158
- });
1159
- } catch (error) {
1160
- if (!(error instanceof PreparedOnly)) throw new Error("Could not prepare this Ark payment. Check funds, address and provider.");
1161
- }
1162
- const capture = this.preview.take();
1163
- if (!capture) throw new Error("The Ark adapter did not prepare a transaction");
1164
- const tx = Transaction$1.fromPSBT(unbase64(capture.signedTx));
1165
- const destination = ArkAddress.decode(target.address);
1166
- let received = 0n, input = 0n, output = 0n;
1167
- for (let i = 0; i < tx.inputsLength; i++) {
1168
- const prev = tx.getInput(i).witnessUtxo;
1169
- if (!prev) throw new Error("Missing Ark input amount");
1170
- input += prev.amount;
1171
- }
1172
- for (let i = 0; i < tx.outputsLength; i++) {
1173
- const out = tx.getOutput(i);
1174
- output += out.amount ?? 0n;
1175
- if (out.script && [hex$10(destination.pkScript), hex$10(destination.subdustPkScript)].includes(hex$10(out.script))) received += out.amount ?? 0n;
1176
- }
1177
- const fee = Number(input - output);
1178
- if (received !== BigInt(amount) || !Number.isSafeInteger(fee) || fee < 0 || fee > feeCap) throw new Error("Prepared amount or fee does not match your review");
1179
- return {
1180
- fee,
1181
- prepared: {
1182
- ...capture,
1183
- txid: tx.id,
1184
- address: target.address,
1185
- amount,
1186
- fee
1187
- }
1188
- };
1189
- }
1190
- async executeOperation(review, p, persist) {
1191
- this.checkPrepared(review, p);
1192
- const info = await this.provider.getInfo();
1193
- if (info.network !== this.config.network || info.signerPubkey !== this.config.serverKey) throw new Error("Ark provider configuration changed");
1194
- const tx = Transaction$1.fromPSBT(unbase64(p.signedTx));
1195
- const response = await this.provider.submitTx(p.signedTx, p.checkpoints);
1196
- assertSubmittedArkTxid(response, tx, "Ghostly Ark submission");
1197
- if (!response.finalArkTx) throw new Error("Provider returned no signed transaction");
1198
- this.verifyServer(Transaction$1.fromPSBT(unbase64(response.finalArkTx)), tx);
1199
- const matched = matchServerCheckpoints(response.signedCheckpointTxs, p.checkpoints.map((c) => Transaction$1.fromPSBT(unbase64(c))), "Ghostly Ark checkpoints");
1200
- const contracts = await (await this.wallet.getContractManager()).getContracts();
1201
- const final = await Promise.all(matched.map(async ({ server, local }) => {
1202
- assertAllowedSighashTypes(server);
1203
- this.verifyServer(server, local);
1204
- let signed = server;
1205
- for (let index = 0; index < local.inputsLength; index++) {
1206
- const script = local.getInput(index).witnessUtxo?.script;
1207
- const contract = script && contracts.find((c) => c.script === hex$10(script));
1208
- if (!contract || contract.type !== "default") throw new Error("Checkpoint is not owned by this wallet profile");
1209
- const descriptor = contract.metadata?.signingDescriptor;
1210
- const signer = typeof descriptor === "string" ? await this.wallet.signerForDescriptor(descriptor) : contract.params.pubKey === hex$10(await this.identity.xOnlyPublicKey()) ? this.identity : void 0;
1211
- if (!signer) throw new Error("Checkpoint signing descriptor missing");
1212
- signed = await signer.sign(signed, [index]);
1213
- }
1214
- return base64$1(signed.toPSBT());
1215
- }));
1216
- p.finalCheckpoints = final;
1217
- await persist?.();
1218
- await this.provider.finalizeTx(p.txid, final);
1219
- return {
1220
- txid: p.txid,
1221
- settled: true
1222
- };
1223
- }
1224
- async reconcileOperation(review, p, persist) {
1225
- this.checkPrepared(review, p);
1226
- if (await this.verifyReceipt(p.txid, p.address, p.amount)) return {
1227
- txid: p.txid,
1228
- settled: true
1229
- };
1230
- if (!p.finalCheckpoints) {
1231
- const state = await this.repository.getWalletState();
1232
- if (!state) throw new Error("Ark wallet state is unavailable");
1233
- await this.repository.saveWalletState({
1234
- ...state,
1235
- settings: {
1236
- ...state.settings,
1237
- hasPendingTx: true
1238
- }
1239
- });
1240
- this.preview.authorizedFinalize = {
1241
- txid: p.txid,
1242
- run: async (checkpoints) => {
1243
- const matched = matchServerCheckpoints(checkpoints, p.checkpoints.map((c) => Transaction$1.fromPSBT(unbase64(c))), "Ghostly pending recovery");
1244
- for (const { server, local } of matched) {
1245
- assertAllowedSighashTypes(server);
1246
- this.verifyServer(server, local);
1247
- }
1248
- p.finalCheckpoints = checkpoints;
1249
- await persist?.();
1250
- await this.provider.finalizeTx(p.txid, checkpoints);
1251
- }
1252
- };
1253
- try {
1254
- await this.wallet.finalizePendingTxs();
1255
- } finally {
1256
- this.preview.authorizedFinalize = void 0;
1257
- }
1258
- }
1259
- if (p.finalCheckpoints) try {
1260
- await this.provider.finalizeTx(p.txid, p.finalCheckpoints);
1261
- } catch {}
1262
- return {
1263
- txid: p.txid,
1264
- settled: await this.verifyReceipt(p.txid, p.address, p.amount)
1265
- };
1266
- }
1267
- /**
1268
- * A virtual output paying `address` at least `amount`, made after `since` and none of `claimed`: how a
1269
- * request paid from another Ark wallet is seen, with no receipt from the payer.
1270
- */
1271
- async received(address, amount, since, claimed) {
1272
- const destination = ArkAddress.decode(address);
1273
- const scripts = [hex$10(destination.pkScript), hex$10(destination.subdustPkScript)];
1274
- const { vtxos } = await this.indexer.getVtxos({ scripts });
1275
- return vtxos.find((v) => /^[a-f0-9]{64}$/.test(v.txid) && v.value >= amount && scripts.includes(v.script) && !claimed.has(v.txid) && v.createdAt.getTime() >= since - 6e4)?.txid;
1276
- }
1277
- async verifyReceipt(txid, address, amount) {
1278
- if (!/^[a-f0-9]{64}$/.test(txid)) return false;
1279
- const destination = ArkAddress.decode(address);
1280
- return (await this.indexer.getVtxos({ outpoints: [{
1281
- txid,
1282
- vout: 0
1283
- }] })).vtxos.some((v) => v.txid === txid && v.value === amount && [hex$10(destination.pkScript), hex$10(destination.subdustPkScript)].includes(v.script));
1284
- }
1285
- checkTarget(t) {
1286
- if (t.method !== this.method || t.provider !== this.config.provider || t.network !== this.config.network || t.asset !== "BTC" || t.unit !== "sat") throw new Error("Payment method, network or provider does not match this wallet");
1287
- const decoded = ArkAddress.decode(t.address);
1288
- const pinned = this.config.serverKey.replace(/^(02|03)(?=[a-f0-9]{64}$)/, "");
1289
- if (hex$10(decoded.serverPubKey) !== pinned || decoded.hrp !== (this.config.network === "bitcoin" ? "ark" : "tark")) throw new Error("Ark address belongs to another provider or network");
1290
- }
1291
- verifyServer(server, local) {
1292
- const key = this.config.serverKey.replace(/^(02|03)(?=[a-f0-9]{64}$)/, "");
1293
- for (let index = 0; index < local.inputsLength; index++) {
1294
- const leaf = local.getInput(index).tapLeafScript?.[0]?.[1];
1295
- if (!leaf) throw new Error("Missing reviewed spend leaf");
1296
- verifyTapscriptSignatures(server, index, [key], void 0, void 0, tapLeafHash(leaf.subarray(0, -1), leaf[leaf.length - 1]));
1297
- }
1298
- }
1299
- checkPrepared(r, p) {
1300
- this.checkTarget(r);
1301
- if (p.address !== r.address || p.amount !== r.amount || p.fee !== r.fee || p.fee > r.feeCap || Transaction$1.fromPSBT(unbase64(p.signedTx)).id !== p.txid) throw new Error("Prepared payment does not match the approved review");
1302
- }
1303
- };
1304
- //#endregion
1305
547
  //#region ../browser/src/engine/paymentAdapters/backup.ts
1306
548
  /**
1307
549
  * Wallet database versions a backup may carry. 3 is what SDK 0.4.74-0.4.76 made (Ghostly 1.1.3 and earlier);
@@ -1399,6 +641,8 @@ async function restoreArkDatabase(walletId, snapshot) {
1399
641
  }
1400
642
  //#endregion
1401
643
  //#region ../browser/src/engine/paymentAdapters/arkWallet.ts
644
+ /** The Ark SDK, a large part of the app: loaded once an Ark wallet is made, opened or restored, not with the engine. */
645
+ var loadArkade = () => import("./arkade-3szZ1IpP.js");
1402
646
  /** Every new profile starts with this wallet: nothing to set up before receiving. */
1403
647
  var DEFAULT_ARK = {
1404
648
  network: "bitcoin",
@@ -1530,6 +774,7 @@ var ArkWallet = class {
1530
774
  }
1531
775
  async createNow(params) {
1532
776
  refuseAway(`arkade:${this.network}`, "Ark");
777
+ const [{ ARK_NETWORKS, ArkadeAdapter }, { RestArkProvider }] = await Promise.all([loadArkade(), import("@arkade-os/sdk")]);
1533
778
  if (!ARK_NETWORKS.includes(params.network)) throw new Error("Unsupported Ark network");
1534
779
  if (arkMode(params.network) !== this.network) throw new WrongNetworkError(arkMode(params.network), `${params.network === "bitcoin" ? "Bitcoin" : params.network} is a ${networkLabel(arkMode(params.network))} network: this is the ${networkLabel(this.network)} Ark wallet`);
1535
780
  checkProviders(params.network, params.provider, params.explorer);
@@ -1581,6 +826,7 @@ var ArkWallet = class {
1581
826
  const key = this.saved.deviceKey ?? password;
1582
827
  if (!key) throw new Error("Enter the Ark wallet password");
1583
828
  const mnemonic = await unsealSeed(this.saved.seed, key);
829
+ const { ArkadeAdapter } = await loadArkade();
1584
830
  this.adapter = await this.gate.within(ArkadeAdapter.connect(this.saved.config, mnemonic), (a) => a.dispose());
1585
831
  await this.refresh();
1586
832
  }
@@ -1667,6 +913,7 @@ var ArkWallet = class {
1667
913
  ...payload.config,
1668
914
  walletId: crypto.randomUUID()
1669
915
  };
916
+ const { ARK_NETWORKS } = await loadArkade();
1670
917
  if (!ARK_NETWORKS.includes(config.network)) throw new Error("Unsupported Ark network in backup");
1671
918
  if (arkMode(config.network) !== this.network) throw new WrongNetworkError(arkMode(config.network), `This backup is a ${networkLabel(arkMode(config.network))} Ark wallet`);
1672
919
  checkProviders(config.network, config.provider, config.explorer);
@@ -4392,14 +3639,14 @@ var BitcoinService = class {
4392
3639
  return this.target(await provider.receiveAddress(), (await provider.info()).network);
4393
3640
  }
4394
3641
  /**
4395
- * A transaction of the active source paying `target` at least `amount`, none of `claimed`. With a source
4396
- * that can list what an address received, that is the proof; otherwise the contact's `hint` txid must be
4397
- * one of the source's own receives of at least the amount.
3642
+ * A transaction of the active source paying `target` at least `amount`, none of `claimed`: what the source
3643
+ * says that address received is the only proof. A contact's txid proves nothing (it can name any receive of
3644
+ * the wallet), so a source that cannot list an address's receives never sees a request paid.
4398
3645
  */
4399
- async received(target, amount, claimed, hint) {
3646
+ async received(target, amount, claimed) {
4400
3647
  const provider = this.sources.active;
4401
- if (!provider || target.method !== "bitcoin" || (await provider.info()).network !== target.network) return void 0;
4402
- const tx = (provider.received ? await provider.received(target.address) : hint ? (await provider.history(100)).filter((tx) => tx.txid === hint) : []).find((tx) => tx.amount >= amount && !claimed.has(tx.txid) && /^[0-9a-f]{64}$/.test(tx.txid));
3648
+ if (!provider?.received || target.method !== "bitcoin" || (await provider.info()).network !== target.network) return void 0;
3649
+ const tx = (await provider.received(target.address)).find((tx) => tx.amount >= amount && !claimed.has(tx.txid) && /^[0-9a-f]{64}$/.test(tx.txid));
4403
3650
  return tx && {
4404
3651
  txid: tx.txid,
4405
3652
  confirmations: tx.confirmations
@@ -5587,7 +4834,7 @@ var BitcoindOnchain = class BitcoindOnchain {
5587
4834
  lockUnspents: true,
5588
4835
  replaceable: true
5589
4836
  }), "funded transaction");
5590
- const inputs = this.outpoints(Transaction$2.fromPSBT(base64.decode(text$2(funded.psbt, "funded transaction")), TX_OPTS));
4837
+ const inputs = this.outpoints(Transaction.fromPSBT(base64.decode(text$2(funded.psbt, "funded transaction")), TX_OPTS));
5591
4838
  try {
5592
4839
  const fee = btcToSats(funded.fee);
5593
4840
  if (fee > request.feeCap) throw new Error(`The fee (${fee} sats) is above your limit of ${request.feeCap}`);
@@ -5595,7 +4842,7 @@ var BitcoindOnchain = class BitcoindOnchain {
5595
4842
  if (signed.complete !== true) throw new Error("The wallet could not sign this payment (is it locked, or watch-only?)");
5596
4843
  const final = object(await this.call("finalizepsbt", signed.psbt, false), "finalized transaction");
5597
4844
  if (final.complete !== true) throw new Error("The wallet could not finalize this payment");
5598
- return this.check(Transaction$2.fromPSBT(base64.decode(text$2(final.psbt, "finalized transaction")), TX_OPTS), request, inputs, feeRate, network);
4845
+ return this.check(Transaction.fromPSBT(base64.decode(text$2(final.psbt, "finalized transaction")), TX_OPTS), request, inputs, feeRate, network);
5599
4846
  } catch (error) {
5600
4847
  await this.unlock(inputs).catch(() => {});
5601
4848
  throw error;
@@ -5605,16 +4852,17 @@ var BitcoindOnchain = class BitcoindOnchain {
5605
4852
  check(tx, request, funded, feeRate, network) {
5606
4853
  const same = (a, b) => a.length === b.length && a.every((o, i) => o.txid === b[i].txid && o.vout === b[i].vout);
5607
4854
  if (!same(this.outpoints(tx), funded)) throw new Error("The node signed other coins than it funded the payment with");
4855
+ const script = (() => {
4856
+ try {
4857
+ return hex.encode(OutScript.encode(Address(BTC_NETWORKS[network]).decode(request.address)));
4858
+ } catch {
4859
+ return;
4860
+ }
4861
+ })();
5608
4862
  let paid = 0;
5609
4863
  for (let i = 0; i < tx.outputsLength; i++) {
5610
4864
  const output = tx.getOutput(i);
5611
- if ((() => {
5612
- try {
5613
- return tx.getOutputAddress(i, BTC_NETWORKS[network]);
5614
- } catch {
5615
- return;
5616
- }
5617
- })() === request.address) paid += Number(output.amount ?? 0n);
4865
+ if (script !== void 0 && output.script && hex.encode(output.script) === script) paid += Number(output.amount ?? 0n);
5618
4866
  }
5619
4867
  if (paid !== request.amount) throw new Error("The node built a transaction that does not pay the address and amount asked for");
5620
4868
  const fee = Number(tx.fee);
@@ -5647,7 +4895,7 @@ var BitcoindOnchain = class BitcoindOnchain {
5647
4895
  }
5648
4896
  signedTx(prepared) {
5649
4897
  if (!/^[0-9a-f]+$/.test(prepared.signed)) throw new Error("This prepared payment has no signed transaction");
5650
- const tx = Transaction$2.fromRaw(hex.decode(prepared.signed), TX_OPTS);
4898
+ const tx = Transaction.fromRaw(hex.decode(prepared.signed), TX_OPTS);
5651
4899
  if (tx.id !== prepared.txid) throw new Error("This prepared payment's transaction is not the one reviewed");
5652
4900
  return tx;
5653
4901
  }
@@ -5748,6 +4996,24 @@ var BitcoindOnchain = class BitcoindOnchain {
5748
4996
  }
5749
4997
  return [...byTxid.values()].reverse().slice(0, Math.max(0, limit));
5750
4998
  }
4999
+ /** The wallet's receives on this address, newest first: only `receive` entries paying exactly it count. */
5000
+ async received(address) {
5001
+ const entries = await this.call("listtransactions", "*", 200, 0, true);
5002
+ if (!Array.isArray(entries)) throw new Error("The node returned no transaction list");
5003
+ const byTxid = /* @__PURE__ */ new Map();
5004
+ for (const raw of entries) {
5005
+ if (!raw || raw.category !== "receive" || raw.address !== address || typeof raw.txid !== "string" || !TXID.test(raw.txid)) continue;
5006
+ const tx = byTxid.get(raw.txid) ?? {
5007
+ txid: raw.txid,
5008
+ amount: 0,
5009
+ confirmations: typeof raw.confirmations === "number" ? Math.max(0, raw.confirmations) : 0
5010
+ };
5011
+ tx.amount += btcToSats(raw.amount);
5012
+ byTxid.delete(raw.txid);
5013
+ byTxid.set(raw.txid, tx);
5014
+ }
5015
+ return [...byTxid.values()].reverse();
5016
+ }
5751
5017
  async close() {
5752
5018
  this.closed = true;
5753
5019
  }
@@ -11885,6 +11151,16 @@ var STAGED = {
11885
11151
  /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
11886
11152
  var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
11887
11153
  var bundleId = (digest) => `app-${digest}`;
11154
+ /**
11155
+ * A listing's URLs this client reads, in order, a jsDelivr copy first: one this client reads names a full commit, so it
11156
+ * holds exactly the version the store reviewed, where a raw `HEAD` may have moved on to one it has not (WISP 1200 §
11157
+ * Stores), and a 16 MiB bundle is not downloaded only to be skipped.
11158
+ */
11159
+ function listingUrls(urls) {
11160
+ const readable = urls.filter(isAppFetchUrl);
11161
+ const pinned = (u) => new URL(u).hostname.toLowerCase() === "cdn.jsdelivr.net";
11162
+ return [...readable.filter(pinned), ...readable.filter((u) => !pinned(u))];
11163
+ }
11888
11164
  /** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
11889
11165
  function fail(code, words) {
11890
11166
  throw new Error(`${code}: ${words}`);
@@ -11903,6 +11179,15 @@ var versionOf = (manifest, digest) => ({
11903
11179
  digest
11904
11180
  });
11905
11181
  var byteLength = (text) => utf8Encode(text).length;
11182
+ /** The app with these revocations merged into the ones it holds, each kept once. */
11183
+ function withRevocations(app, read) {
11184
+ const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
11185
+ for (const r of read) merged.set(canonicalJson(r), r);
11186
+ return merged.size ? {
11187
+ ...app,
11188
+ revocations: [...merged.values()]
11189
+ } : app;
11190
+ }
11906
11191
  /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
11907
11192
  var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
11908
11193
  function done(tx) {
@@ -11939,13 +11224,28 @@ var Apps = class {
11939
11224
  staged = /* @__PURE__ */ new Map();
11940
11225
  /** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
11941
11226
  verified = /* @__PURE__ */ new Map();
11227
+ /** Reads of a bundle under way, by digest: icons drawn at once share one read. */
11228
+ verifying = /* @__PURE__ */ new Map();
11229
+ /**
11230
+ * The `icon.png` of each checked bundle, by digest (at most 256 KiB, or null for none): every icon shown on the Apps
11231
+ * page, in a chat's cards and in the composer's Apps is read without the whole bundle again.
11232
+ */
11233
+ icons = /* @__PURE__ */ new Map();
11942
11234
  /** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
11943
11235
  usage = /* @__PURE__ */ new Map();
11944
11236
  queues = /* @__PURE__ */ new Map();
11945
11237
  timer = null;
11238
+ /** Drops what `staged` holds past `STAGED.ms`, set while it holds anything: a closed install screen sends no call. */
11239
+ sweep = null;
11946
11240
  checking = null;
11947
11241
  /** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
11948
11242
  anyway = /* @__PURE__ */ new Set();
11243
+ views = null;
11244
+ /**
11245
+ * Listing URLs whose peek showed nothing to take (`<store> <ref> <url>` → `<index digest> <listed digest>`): the same
11246
+ * index listing the same is not peeked again at every check.
11247
+ */
11248
+ listingMisses = /* @__PURE__ */ new Map();
11949
11249
  stopped = false;
11950
11250
  constructor(host) {
11951
11251
  this.host = host;
@@ -11966,6 +11266,9 @@ var Apps = class {
11966
11266
  this.stopped = true;
11967
11267
  if (this.timer) clearTimeout(this.timer);
11968
11268
  this.timer = null;
11269
+ if (this.sweep) clearTimeout(this.sweep);
11270
+ this.sweep = null;
11271
+ this.staged.clear();
11969
11272
  }
11970
11273
  schedule(ms, atStart = false) {
11971
11274
  if (this.timer) clearTimeout(this.timer);
@@ -12010,7 +11313,7 @@ var Apps = class {
12010
11313
  * or not the page stops its frame.
12011
11314
  */
12012
11315
  async stillRunnable(app) {
12013
- const run = this.runStatus(app, await this.stores(), app.revocations);
11316
+ const run = this.runStatus(app, await this.heldViews(), app.revocations);
12014
11317
  if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
12015
11318
  if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12016
11319
  return app;
@@ -12029,11 +11332,42 @@ var Apps = class {
12029
11332
  async putApp(app) {
12030
11333
  await wrap((await store(STORES$1.apps, "readwrite")).put(app));
12031
11334
  }
11335
+ /**
11336
+ * Writes an app's record back only while that install is still there (the same `installedAt`), read and written in
11337
+ * one transaction: an app uninstalled (or installed again) while the update check was reading is not put back.
11338
+ */
11339
+ async putAppIfInstalled(app, installedAt) {
11340
+ const tx = (await openDb()).transaction(STORES$1.apps, "readwrite");
11341
+ let put = false;
11342
+ const read = tx.objectStore(STORES$1.apps).get(app.ref);
11343
+ read.onsuccess = () => {
11344
+ if (read.result?.installedAt !== installedAt) return;
11345
+ tx.objectStore(STORES$1.apps).put(app);
11346
+ put = true;
11347
+ };
11348
+ await done(tx);
11349
+ return put;
11350
+ }
12032
11351
  async storeRecord(key) {
12033
11352
  return wrap((await store(STORES$1.appStores, "readonly")).get(key));
12034
11353
  }
12035
11354
  async putStore(record) {
12036
11355
  await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
11356
+ this.views = null;
11357
+ }
11358
+ async deleteStore(key) {
11359
+ await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
11360
+ this.views = null;
11361
+ }
11362
+ /**
11363
+ * The stores' views, read once and again after a store is written here (the only writer: pages in other tabs share
11364
+ * this engine). An app's every storage call and chat frame checks them, and an index may be 4 MiB.
11365
+ */
11366
+ heldViews() {
11367
+ return this.views ??= this.stores().then((s) => this.storeViews(s), (error) => {
11368
+ this.views = null;
11369
+ throw error;
11370
+ });
12037
11371
  }
12038
11372
  /** The stores the person has (a removed default is not one). */
12039
11373
  async stores() {
@@ -12055,8 +11389,8 @@ var Apps = class {
12055
11389
  kind: s.index.kind
12056
11390
  }));
12057
11391
  }
12058
- runStatus(version, stores, revocations = []) {
12059
- const check = checkAppBeforeRun(version, this.storeViews(stores), revocations);
11392
+ runStatus(version, views, revocations = []) {
11393
+ const check = checkAppBeforeRun(version, views, revocations);
12060
11394
  if (check.status === "revoked") return {
12061
11395
  status: "revoked",
12062
11396
  ...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
@@ -12115,19 +11449,34 @@ var Apps = class {
12115
11449
  }
12116
11450
  async removeBundle(kind, digest) {
12117
11451
  this.verified.delete(digest);
11452
+ this.icons.delete(digest);
12118
11453
  await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
12119
11454
  }
11455
+ /** A bundle's bytes removed unless the app's record (installed again meanwhile) holds that version, or waits for it. */
11456
+ async removeUnusedBundle(ref, kind, digest) {
11457
+ const app = await this.app(ref);
11458
+ if (app?.digest !== digest && app?.pending?.digest !== digest) await this.removeBundle(kind, digest);
11459
+ }
12120
11460
  /** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
12121
- async verifiedBundle(app) {
11461
+ verifiedBundle(app) {
12122
11462
  const kept = this.verified.get(app.digest);
12123
- if (kept) return kept;
12124
- const bytes = await this.readBundleBytes(app.bytes, app.digest);
12125
- if (!bytes) fail("needs-files", "This app's files are not on this device yet");
12126
- const read = readAppBundle(bytes);
12127
- if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
12128
- if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
12129
- this.verified.set(app.digest, read.bundle);
12130
- return read.bundle;
11463
+ if (kept) return Promise.resolve(kept);
11464
+ const under = this.verifying.get(app.digest);
11465
+ if (under) return under;
11466
+ const reading = (async () => {
11467
+ const bytes = await this.readBundleBytes(app.bytes, app.digest);
11468
+ if (!bytes) fail("needs-files", "This app's files are not on this device yet");
11469
+ const read = readAppBundle(bytes);
11470
+ if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
11471
+ if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
11472
+ this.verified.set(app.digest, read.bundle);
11473
+ this.icons.set(app.digest, read.bundle.files.get("icon.png")?.slice() ?? null);
11474
+ return read.bundle;
11475
+ })().finally(() => {
11476
+ this.verifying.delete(app.digest);
11477
+ });
11478
+ this.verifying.set(app.digest, reading);
11479
+ return reading;
12131
11480
  }
12132
11481
  async fetchBundle(url) {
12133
11482
  const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
@@ -12138,17 +11487,36 @@ var Apps = class {
12138
11487
  bytes
12139
11488
  };
12140
11489
  }
12141
- async readStoreAt(url, heldKey) {
11490
+ /**
11491
+ * Reads the index at `url`. With the index held for this store, bytes equal to it (by digest) are the held index: its
11492
+ * signature and every revocation in it were checked when it was read, so only its expiry is taken again.
11493
+ */
11494
+ async readStoreAt(url, heldKey, held) {
12142
11495
  const [indexBytes, sigBytes] = await Promise.all([this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.storeIndexBytes }), this.host.fetch(besideUrl(url, "ghostly-store.sig"), { maxBytes: APP_FETCH_LIMITS.sigBytes })]);
11496
+ if (held?.index && held.digest !== void 0 && toBase64Url(sha256(indexBytes)) === held.digest) return {
11497
+ ok: true,
11498
+ store: {
11499
+ index: held.index,
11500
+ digest: held.digest,
11501
+ expired: held.index.expires < this.nowS()
11502
+ }
11503
+ };
12143
11504
  return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
12144
11505
  }
12145
- /** The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error. */
12146
- async readRevocations(ref, urls) {
12147
- const out = [];
12148
- for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) try {
12149
- const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
12150
- if (read.ok) out.push(...read.revocations.filter((r) => r.statement.app === ref));
12151
- } catch {}
11506
+ /**
11507
+ * The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error.
11508
+ * `read` holds the files already read, which are skipped, and gets the ones read now. Null when no file answered now.
11509
+ */
11510
+ async readRevocations(ref, urls, read = /* @__PURE__ */ new Set()) {
11511
+ let out = null;
11512
+ for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) {
11513
+ if (read.has(url)) continue;
11514
+ read.add(url);
11515
+ try {
11516
+ const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
11517
+ if (read.ok) (out ??= []).push(...read.revocations.filter((r) => r.statement.app === ref));
11518
+ } catch {}
11519
+ }
12152
11520
  return out;
12153
11521
  }
12154
11522
  summary(s) {
@@ -12281,7 +11649,12 @@ var Apps = class {
12281
11649
  preloaded: true,
12282
11650
  removed: true
12283
11651
  });
12284
- else await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
11652
+ else await this.deleteStore(key);
11653
+ for (const app of await this.installed()) {
11654
+ if (app.store !== key) continue;
11655
+ const { store: _s, ...unpinned } = app;
11656
+ await this.putApp(unpinned);
11657
+ }
12285
11658
  }
12286
11659
  /** Reads one store again, or every store (the person opened the Apps page, or the update check). */
12287
11660
  async refreshStores({ key } = {}) {
@@ -12291,7 +11664,7 @@ var Apps = class {
12291
11664
  for (const s of stores) {
12292
11665
  let next;
12293
11666
  try {
12294
- const read = await this.readStoreAt(s.url, s.key);
11667
+ const read = await this.readStoreAt(s.url, s.key, s);
12295
11668
  next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
12296
11669
  ...s,
12297
11670
  problem: read.reason
@@ -12310,23 +11683,28 @@ var Apps = class {
12310
11683
  }
12311
11684
  /**
12312
11685
  * Fetches an app and checks it, for the install screen: from a pasted URL, a store's listing (its URLs in order) or a
12313
- * chat card's pointer. A store's or a card's `sequence` and digest must hold: the same app, and that version or a newer
12314
- * one. Nothing is stored: the checked bundle waits in memory for `install`.
11686
+ * chat card's pointer. A card's `sequence` and digest must hold: the same app, and that version or a newer one. A
11687
+ * store's listing must hold exactly: the version it lists, by digest, so a URL that already holds a newer version the
11688
+ * store has not reviewed is skipped for the next one (`unlisted` when none holds it). Nothing is stored: the checked
11689
+ * bundle waits in memory for `install`.
12315
11690
  */
12316
11691
  async preview(source) {
12317
11692
  let urls;
12318
11693
  let expect = null;
11694
+ let fromStore;
12319
11695
  if ("url" in source) urls = [typeof source.url === "string" && appPasteUrl(source.url, "app.ghostlyapp") || notReadable(source.url, "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS")];
12320
11696
  else if ("store" in source) {
12321
11697
  const s = await this.storeRecord(source.store);
12322
11698
  const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
12323
11699
  if (!listing) fail("not-listed", "This store does not list this app");
12324
- urls = listing.urls.filter(isAppFetchUrl);
11700
+ urls = listingUrls(listing.urls);
12325
11701
  expect = {
12326
11702
  ref: listing.ref,
12327
11703
  sequence: listing.sequence,
12328
- digest: listing.digest
11704
+ digest: listing.digest,
11705
+ exact: true
12329
11706
  };
11707
+ fromStore = s.key;
12330
11708
  } else if ("card" in source && source.card) {
12331
11709
  const { ref, url, sequence, digest } = source.card;
12332
11710
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
@@ -12347,14 +11725,22 @@ var Apps = class {
12347
11725
  if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
12348
11726
  if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
12349
11727
  if (expect.sequence === version.sequence && expect.digest !== void 0 && expect.digest !== version.digest) fail("equivocation", "The URL holds another version under the same number");
11728
+ if (expect.exact && version.digest !== expect.digest) fail("unlisted", "The URL holds a version the store does not list");
12350
11729
  }
12351
- return this.stage(bundle, bytes, url);
11730
+ return this.stage(bundle, bytes, url, fromStore);
12352
11731
  } catch (error) {
12353
11732
  last = error;
12354
11733
  }
12355
11734
  throw last instanceof Error ? last : new Error(String(last));
12356
11735
  }
12357
- async stage(bundle, bytes, from) {
11736
+ /** Drops the staged bundles past `STAGED.ms`, and comes back when the oldest left is due. */
11737
+ sweepStaged() {
11738
+ this.sweep = null;
11739
+ for (const [digest, s] of this.staged) if (this.now() - s.at >= STAGED.ms) this.staged.delete(digest);
11740
+ const oldest = this.staged.values().next().value;
11741
+ if (oldest) this.sweep = setTimeout(() => this.sweepStaged(), STAGED.ms - (this.now() - oldest.at));
11742
+ }
11743
+ async stage(bundle, bytes, from, fromStore) {
12358
11744
  for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
12359
11745
  this.staged.delete(bundle.digest);
12360
11746
  while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
@@ -12362,8 +11748,10 @@ var Apps = class {
12362
11748
  bundle,
12363
11749
  bytes,
12364
11750
  from,
11751
+ ...fromStore !== void 0 && { store: fromStore },
12365
11752
  at: this.now()
12366
11753
  });
11754
+ if (!this.sweep) this.sweepStaged();
12367
11755
  const { manifest, digest } = bundle;
12368
11756
  const version = versionOf(manifest, digest);
12369
11757
  const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
@@ -12381,7 +11769,7 @@ var Apps = class {
12381
11769
  icon: bundle.files.get("icon.png")?.slice() ?? null,
12382
11770
  install,
12383
11771
  asks,
12384
- run: this.runStatus(version, stores, installed?.revocations),
11772
+ run: this.runStatus(version, this.storeViews(stores), installed?.revocations),
12385
11773
  listedBy,
12386
11774
  unknownPublisher: !listedBy.some((s) => s.kind === "curated")
12387
11775
  };
@@ -12397,12 +11785,12 @@ var Apps = class {
12397
11785
  this.staged.delete(digest);
12398
11786
  fail("expired", "Fetch the app again");
12399
11787
  }
12400
- const { bundle, bytes, from } = staged;
11788
+ const { bundle, bytes, from, store: fromStore } = staged;
12401
11789
  const { manifest } = bundle;
12402
11790
  if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
12403
11791
  const version = versionOf(manifest, digest);
12404
11792
  const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
12405
- const run = this.runStatus(version, stores, installed?.revocations);
11793
+ const run = this.runStatus(version, this.storeViews(stores), installed?.revocations);
12406
11794
  if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12407
11795
  if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12408
11796
  const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
@@ -12420,26 +11808,37 @@ var Apps = class {
12420
11808
  fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
12421
11809
  }
12422
11810
  if (decision === "same" && installed) {
12423
- if (!await this.hasBundle(installed.bytes, digest)) await this.putApp({
12424
- ...installed,
12425
- bytes: await this.writeBundle(digest, bytes)
12426
- });
11811
+ const files = await this.hasBundle(installed.bytes, digest) ? null : await this.writeBundle(digest, bytes);
11812
+ const pin = fromStore !== void 0 && installed.store !== fromStore;
11813
+ if (files || pin) {
11814
+ const { pending, ...rest } = installed;
11815
+ if (pin && pending) await this.removeBundle(pending.bytes, pending.digest);
11816
+ await this.putApp({
11817
+ ...pin ? rest : installed,
11818
+ ...files && { bytes: files },
11819
+ ...pin && { store: fromStore }
11820
+ });
11821
+ }
12427
11822
  this.staged.delete(digest);
12428
11823
  return this.view(await this.app(version.ref), stores);
12429
11824
  }
12430
11825
  const kind = await this.writeBundle(digest, bytes);
12431
- const next = this.versionRecord(installed, bundle, kind, from);
11826
+ const next = this.versionRecord(installed, bundle, kind, from, fromStore);
12432
11827
  await this.putApp(next);
12433
11828
  if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
12434
- if (installed?.pending && installed.pending.sequence <= version.sequence && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
11829
+ if (installed?.pending && next.pending?.digest !== installed.pending.digest && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
12435
11830
  this.staged.delete(digest);
12436
11831
  return this.view(next, stores);
12437
11832
  }
12438
- /** The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). */
12439
- versionRecord(installed, bundle, kind, from) {
11833
+ /**
11834
+ * The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). `store`
11835
+ * is the store its updates come from (`InstalledApp.store`); a newer version waiting is kept only while that is
11836
+ * unchanged, since it was found under the other rule.
11837
+ */
11838
+ versionRecord(installed, bundle, kind, from, store) {
12440
11839
  const { manifest, digest } = bundle;
12441
11840
  const now = this.now();
12442
- const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence ? installed.pending : void 0;
11841
+ const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence && installed.store === store ? installed.pending : void 0;
12443
11842
  return {
12444
11843
  ref: appRef(manifest.publisher, manifest.name),
12445
11844
  publisher: manifest.publisher,
@@ -12450,6 +11849,7 @@ var Apps = class {
12450
11849
  manifest,
12451
11850
  bytes: kind,
12452
11851
  from,
11852
+ ...store !== void 0 && { store },
12453
11853
  installedAt: installed?.installedAt ?? now,
12454
11854
  updatedAt: now,
12455
11855
  ...installed?.revocations?.length && { revocations: installed.revocations },
@@ -12470,7 +11870,8 @@ var Apps = class {
12470
11870
  fail("needs-files", "The update's files are gone; it is fetched again at the next check");
12471
11871
  }
12472
11872
  const stores = await this.stores();
12473
- const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), stores, installed.revocations);
11873
+ if (installed.store !== void 0 && this.pinnedListing(installed, stores)?.digest !== pending.digest) fail("unlisted", "The store no longer lists this update");
11874
+ const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), this.storeViews(stores), installed.revocations);
12474
11875
  if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12475
11876
  if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12476
11877
  const plan = planAppUpdate(installed, {
@@ -12479,15 +11880,20 @@ var Apps = class {
12479
11880
  });
12480
11881
  if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
12481
11882
  const { pending: _p, ...without } = installed;
12482
- const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from);
11883
+ const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from, installed.store);
12483
11884
  await this.putApp(next);
12484
11885
  await this.removeBundle(installed.bytes, installed.digest);
12485
11886
  return this.view(next, stores);
12486
11887
  }
11888
+ /** An app's listing in the store its updates come from (`InstalledApp.store`), while that store is the person's. */
11889
+ pinnedListing(app, stores) {
11890
+ return app.store === void 0 ? void 0 : stores.find((s) => s.key === app.store)?.index?.apps.find((a) => a.ref === app.ref);
11891
+ }
12487
11892
  /**
12488
11893
  * The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
12489
- * app's newer versions looked for in the stores' listings and its manifest's `sources`, and its publisher's
12490
- * revocations read beside its sources. An update that keeps or drops permissions installs by itself; one that adds
11894
+ * app's newer versions looked for in the stores' listings and its manifest's `sources` (for an app installed from a
11895
+ * store, only in that store's listing, at the digest it lists), and its publisher's revocations read beside its
11896
+ * sources. An update that keeps or drops permissions installs by itself; one that adds
12491
11897
  * some waits for the person. One check at a time.
12492
11898
  */
12493
11899
  checkUpdates() {
@@ -12513,21 +11919,65 @@ var Apps = class {
12513
11919
  if (!first) continue;
12514
11920
  let app = first;
12515
11921
  let outcome = "none";
11922
+ let wrote;
11923
+ if (app.store !== void 0 && !stores.some((s) => s.key === app.store)) {
11924
+ const { store: _s, ...unpinned } = app;
11925
+ app = unpinned;
11926
+ }
11927
+ if (app.store !== void 0 && app.pending && stores.find((s) => s.key === app.store)?.index && this.pinnedListing(app, stores)?.digest !== app.pending.digest) {
11928
+ await this.removeBundle(app.pending.bytes, app.pending.digest);
11929
+ const { pending: _p, ...rest } = app;
11930
+ app = rest;
11931
+ }
12516
11932
  const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
12517
11933
  const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
12518
- const candidates = [];
11934
+ const pinned = app.store;
11935
+ const candidates = /* @__PURE__ */ new Map();
11936
+ const newer = (v) => v.ref === app.ref && (v.sequence > highest || v.sequence >= app.sequence && !known.has(v.digest));
12519
11937
  for (const s of stores) {
11938
+ if (pinned !== void 0 && s.key !== pinned) continue;
12520
11939
  const listing = s.index?.apps.find((a) => a.ref === app.ref);
12521
- if (listing && (listing.sequence > highest || listing.sequence >= app.sequence && !known.has(listing.digest))) candidates.push(...listing.urls.filter(isAppFetchUrl));
11940
+ if (!listing || !newer(listing)) continue;
11941
+ const seen = `${s.digest} ${listing.digest}`;
11942
+ for (const url of listingUrls(listing.urls)) {
11943
+ const missed = `${s.key} ${app.ref} ${url}`;
11944
+ if (candidates.has(url) || s.digest !== void 0 && this.listingMisses.get(missed) === seen) continue;
11945
+ candidates.set(url, {
11946
+ ...pinned !== void 0 && { digest: listing.digest },
11947
+ peek: true,
11948
+ ...s.digest !== void 0 && { miss: [missed, seen] }
11949
+ });
11950
+ }
12522
11951
  }
12523
- for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) try {
12524
- const peek = peekAppManifest(await this.host.fetch(url, {
12525
- maxBytes: PEEK_BYTES,
12526
- peek: true
12527
- }));
12528
- if (peek && peek.ref === app.ref && (peek.sequence > highest || peek.sequence >= app.sequence && !known.has(peek.digest))) candidates.push(url);
12529
- } catch {}
12530
- for (const url of new Set(candidates)) {
11952
+ if (pinned === void 0) for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) {
11953
+ if (candidates.has(url)) continue;
11954
+ try {
11955
+ const peek = peekAppManifest(await this.host.fetch(url, {
11956
+ maxBytes: PEEK_BYTES,
11957
+ peek: true
11958
+ }));
11959
+ if (peek && newer(peek)) candidates.set(url, {});
11960
+ } catch {}
11961
+ }
11962
+ const revokeRead = /* @__PURE__ */ new Set();
11963
+ const before = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
11964
+ app = withRevocations(app, before ?? []);
11965
+ for (const [url, want] of candidates) {
11966
+ if (want.peek) {
11967
+ let peek;
11968
+ try {
11969
+ peek = peekAppManifest(await this.host.fetch(url, {
11970
+ maxBytes: PEEK_BYTES,
11971
+ peek: true
11972
+ }));
11973
+ } catch {
11974
+ continue;
11975
+ }
11976
+ if (!peek || !newer(peek) || want.digest !== void 0 && peek.digest !== want.digest) {
11977
+ if (want.miss) this.listingMisses.set(...want.miss);
11978
+ continue;
11979
+ }
11980
+ }
12531
11981
  let fetched;
12532
11982
  try {
12533
11983
  fetched = await this.fetchBundle(url);
@@ -12537,6 +11987,7 @@ var Apps = class {
12537
11987
  const { bundle, bytes } = fetched;
12538
11988
  const version = versionOf(bundle.manifest, bundle.digest);
12539
11989
  if (version.ref !== app.ref || known.has(version.digest)) continue;
11990
+ if (want.digest !== void 0 && version.digest !== want.digest) continue;
12540
11991
  if (app.pending && version.sequence === app.pending.sequence) {
12541
11992
  app = {
12542
11993
  ...app,
@@ -12567,17 +12018,21 @@ var Apps = class {
12567
12018
  }
12568
12019
  continue;
12569
12020
  }
12570
- if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
12021
+ if (this.runStatus(version, this.storeViews(stores), app.revocations).status !== "ok") continue;
12571
12022
  if (app.pending && version.sequence < app.pending.sequence) continue;
12572
12023
  const kind = await this.writeBundle(version.digest, bytes);
12024
+ wrote = {
12025
+ bytes: kind,
12026
+ digest: version.digest
12027
+ };
12573
12028
  if (plan.action === "install") {
12574
12029
  const old = app;
12575
- app = this.versionRecord(app, bundle, kind, url);
12576
- if (old.pending && old.pending.sequence <= version.sequence) {
12577
- await this.removeBundle(old.pending.bytes, old.pending.digest);
12578
- delete app.pending;
12579
- }
12580
- await this.putApp(app);
12030
+ const next = this.versionRecord(app, bundle, kind, url, app.store);
12031
+ const passed = old.pending && old.pending.sequence <= version.sequence ? old.pending : void 0;
12032
+ if (passed) delete next.pending;
12033
+ if (!await this.putAppIfInstalled(next, first.installedAt)) break;
12034
+ app = next;
12035
+ if (passed) await this.removeBundle(passed.bytes, passed.digest);
12581
12036
  await this.removeBundle(old.bytes, old.digest);
12582
12037
  outcome = "updated";
12583
12038
  } else {
@@ -12598,16 +12053,24 @@ var Apps = class {
12598
12053
  }
12599
12054
  break;
12600
12055
  }
12601
- const revocations = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []]);
12602
- const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
12603
- for (const r of revocations) merged.set(canonicalJson(r), r);
12604
- if (!await this.app(app.ref)) continue;
12056
+ const after = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
12057
+ const revocations = before || after ? [...before ?? [], ...after ?? []] : null;
12058
+ const versions = [app, ...app.pending ? [versionOf(app.pending.manifest, app.pending.digest)] : []];
12059
+ const stays = (app.revocations ?? []).filter((r) => !revocations || versions.some((v) => appRevocationFor(v, [r])));
12060
+ const merged = new Map([...stays, ...revocations ?? []].map((r) => [canonicalJson(r), r]));
12061
+ if ((await this.app(app.ref))?.store === void 0 && app.store !== void 0) {
12062
+ const { store: _s, ...unpinned } = app;
12063
+ app = unpinned;
12064
+ }
12605
12065
  app = {
12606
12066
  ...app,
12607
12067
  ...merged.size && { revocations: [...merged.values()] },
12608
12068
  checkedAt: this.now()
12609
12069
  };
12610
- await this.putApp(app);
12070
+ if (!await this.putAppIfInstalled(app, first.installedAt)) {
12071
+ if (wrote) await this.removeUnusedBundle(app.ref, wrote.bytes, wrote.digest);
12072
+ continue;
12073
+ }
12611
12074
  out.push({
12612
12075
  ref: app.ref,
12613
12076
  outcome,
@@ -12620,15 +12083,19 @@ var Apps = class {
12620
12083
  * Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
12621
12084
  * carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
12622
12085
  * taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
12623
- * then its `sources`. Nothing answers: it stays `needs-files`, and it never runs unchecked.
12086
+ * then its `sources` (for an app installed from a store: only that store's listing, never its sources). Nothing
12087
+ * answers: it stays `needs-files`, and it never runs unchecked.
12624
12088
  */
12625
12089
  async fetchFiles({ ref }) {
12626
12090
  const app = await this.installedOrFail(ref);
12627
12091
  const stores = await this.stores();
12628
12092
  if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
12629
12093
  const urls = [app.from];
12630
- for (const s of stores) for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listing.urls);
12631
- urls.push(...app.manifest.sources ?? []);
12094
+ for (const s of stores) {
12095
+ if (app.store !== void 0 && s.key !== app.store) continue;
12096
+ for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listingUrls(listing.urls));
12097
+ }
12098
+ if (app.store === void 0) urls.push(...app.manifest.sources ?? []);
12632
12099
  for (const url of new Set(urls.filter(isAppFetchUrl))) {
12633
12100
  let fetched;
12634
12101
  try {
@@ -12656,7 +12123,7 @@ var Apps = class {
12656
12123
  }
12657
12124
  async runCheckOf(app, stores) {
12658
12125
  if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
12659
- return this.runStatus(app, stores, app.revocations);
12126
+ return this.runStatus(app, this.storeViews(stores), app.revocations);
12660
12127
  }
12661
12128
  /** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
12662
12129
  async runCheck({ ref }) {
@@ -12692,6 +12159,9 @@ var Apps = class {
12692
12159
  /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
12693
12160
  async file({ ref, path }) {
12694
12161
  const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
12162
+ const icon = path === "icon.png" ? this.icons.get(app.digest) : void 0;
12163
+ if (icon) return icon.slice();
12164
+ if (icon === null) fail("no-file", "No such file in this app");
12695
12165
  const bundle = await this.verifiedBundle(app);
12696
12166
  if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
12697
12167
  return bundle.files.get(path).slice();
@@ -12808,7 +12278,12 @@ var Apps = class {
12808
12278
  name
12809
12279
  ])))?.size ?? 0) + size;
12810
12280
  if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
12811
- await wrap((await store(STORES$1.appStorage, "readwrite")).put({
12281
+ const writing = await store(STORES$1.appStorage, "readwrite");
12282
+ if (where !== "alone" && !this.host.isChat(where)) {
12283
+ this.usage.delete(`${app.ref}\0${where}`);
12284
+ fail("bad-scope", "Not a chat of this profile");
12285
+ }
12286
+ await wrap(writing.put({
12812
12287
  ref: app.ref,
12813
12288
  scope: where,
12814
12289
  key: name,
@@ -12853,7 +12328,12 @@ var Apps = class {
12853
12328
  scope: row.scope,
12854
12329
  entries: {}
12855
12330
  };
12856
- out.entries[row.key] = JSON.parse(row.value);
12331
+ Object.defineProperty(out.entries, row.key, {
12332
+ value: JSON.parse(row.value),
12333
+ enumerable: true,
12334
+ writable: true,
12335
+ configurable: true
12336
+ });
12857
12337
  scopes.set(row.scope, out);
12858
12338
  }
12859
12339
  return [...scopes.values()];
@@ -13179,7 +12659,26 @@ function parseDeviceRecord(value) {
13179
12659
  if (!o || typeof o !== "object" || !bytes(o.d) || !bytes(o.tombstone) || !bytes(o.packet) || !(o.at === null || count(o.at, Number.MAX_SAFE_INTEGER))) return bad("ownSet");
13180
12660
  if (!Array.isArray(o.sources) || o.sources.length > 16 || !o.sources.every(text)) return bad("ownSet");
13181
12661
  }
13182
- return value;
12662
+ return cleanNames(value);
12663
+ }
12664
+ /**
12665
+ * The record with every device name as `turnName` makes one: a name is another device's own word, and a build that did
12666
+ * not clean names may have stored it. The same record when there is nothing to clean.
12667
+ */
12668
+ function cleanNames(record) {
12669
+ const dirty = (entry) => !!entry && turnName(entry.name) !== entry.name;
12670
+ if (!record.deviceSet.some(dirty) && !record.unfinishedGrants?.some(dirty)) return record;
12671
+ return {
12672
+ ...record,
12673
+ deviceSet: record.deviceSet.map((slot) => slot && {
12674
+ ...slot,
12675
+ name: turnName(slot.name)
12676
+ }),
12677
+ ...record.unfinishedGrants ? { unfinishedGrants: record.unfinishedGrants.map((grant) => ({
12678
+ ...grant,
12679
+ name: turnName(grant.name)
12680
+ })) } : {}
12681
+ };
13183
12682
  }
13184
12683
  function checked(record) {
13185
12684
  return parseDeviceRecord(record);
@@ -14964,6 +14463,8 @@ async function openTurnKeeper(profile, network, options = {}) {
14964
14463
  signer: (bytes) => identity.key.sign(bytes)
14965
14464
  });
14966
14465
  }
14466
+ /** A joiner with no session yet puts its packet out again this often: another device on the same code may have replaced it. */
14467
+ var ENROLL_REPUT_MS = 5e3;
14967
14468
  /** How long, and how often, the joiner reads the turn record for the record that lists it. */
14968
14469
  var ENROLL_FINISH = {
14969
14470
  rounds: 20,
@@ -15674,6 +15175,11 @@ function ghostLinkEnrollChannel(options) {
15674
15175
  };
15675
15176
  let link = null;
15676
15177
  const rtc = !!options.createPeerConnection;
15178
+ const intervals = options.pollIntervals ?? DHT_POLL_INTERVALS;
15179
+ let offered = false;
15180
+ const reput = pinned && rtc ? setInterval(() => {
15181
+ if (!opened) link?.republish();
15182
+ }, ENROLL_REPUT_MS) : null;
15677
15183
  const tryOpen = () => {
15678
15184
  if (opened || !link?.supportsDevice("enroll/1")) return;
15679
15185
  const hash = link.sessionTranscriptHash, key = credentials.peerKey;
@@ -15696,7 +15202,10 @@ function ghostLinkEnrollChannel(options) {
15696
15202
  native: { automatic: true },
15697
15203
  packetTransports: true,
15698
15204
  transport: options.transport,
15699
- pollIntervals: options.pollIntervals,
15205
+ pollIntervals: {
15206
+ ...intervals,
15207
+ connected: intervals.active
15208
+ },
15700
15209
  autoConnect: true,
15701
15210
  createPeerConnection: () => {
15702
15211
  if (!options.createPeerConnection) throw new ReferenceError("RTCPeerConnection is not defined");
@@ -15728,7 +15237,11 @@ function ghostLinkEnrollChannel(options) {
15728
15237
  },
15729
15238
  onPeerKeyRefused: () => events.onPeerRefused(),
15730
15239
  onPresence: (presence) => {
15731
- if (presence.online) events.onPeerSeen?.();
15240
+ if (!presence.online) return;
15241
+ events.onPeerSeen?.();
15242
+ if (!pinned || !rtc || offered || opened) return;
15243
+ offered = true;
15244
+ link?.connect().catch(() => {});
15732
15245
  },
15733
15246
  onPacketTransports: (transports) => {
15734
15247
  if (!transports.includes("webrtc/1")) startNative();
@@ -15760,6 +15273,7 @@ function ghostLinkEnrollChannel(options) {
15760
15273
  stop: async () => {
15761
15274
  const running = link;
15762
15275
  link = null;
15276
+ if (reput) clearInterval(reput);
15763
15277
  await running?.stop().catch(() => {});
15764
15278
  }
15765
15279
  };
@@ -18718,7 +18232,7 @@ async function room() {
18718
18232
  async function handoffSelf(profileHost) {
18719
18233
  return {
18720
18234
  app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
18721
- db: 14,
18235
+ db: 15,
18722
18236
  pins: { ...WALLET_SDK_PINS },
18723
18237
  kind: profileHost.kind,
18724
18238
  room: await room(),
@@ -20761,6 +20275,7 @@ var Outbox = class {
20761
20275
  return this.turn(async () => {});
20762
20276
  }
20763
20277
  async requeueUnconfirmed() {
20278
+ if (!this.receipts.size) return;
20764
20279
  const messages = await this.store.read();
20765
20280
  for (const id of [...this.receipts.keys()]) {
20766
20281
  if (messages.find((m) => m.id === id)?.via === "pkarr") {
@@ -20880,6 +20395,24 @@ var days = (ms) => {
20880
20395
  };
20881
20396
  //#endregion
20882
20397
  //#region ../browser/src/engine/edits.ts
20398
+ /**
20399
+ * My edits on their way to the contact (WISP 400 § Edits), one chat's worth. What goes is always the latest version of
20400
+ * a message, read from its row: an edit made while an earlier one waits replaces it, so a bot rewriting a status never
20401
+ * queues old versions. It keeps the outbox's resend policy (the window, the attempts, the backoff) in a queue of its
20402
+ * own, since the outbox sends rows and an edited row was delivered long ago.
20403
+ *
20404
+ * An edit goes on a live session where both sides offer edit/1, or on the DHT floor to a contact whose app takes edits
20405
+ * there, at most `EDIT_SEND_LIMIT` per window; the rest wait for the window, a confirmation, or the next session. A
20406
+ * message that never left yet (`waiting`) carries its latest text when it goes, and its edit follows it.
20407
+ */
20408
+ /**
20409
+ * A card that goes again live (`restore`, WISP 405 § Cards that went as text) goes at most once per this long: one a
20410
+ * reconnect owes many go in a trickle, not a burst. Two edit slots of the pace (10 in 10 s), so cards going again
20411
+ * take at most half of it and the author's live updates keep the rest; the CLI paces a card's own updates alike (2.5 s).
20412
+ */
20413
+ var CARD_RESTORE_GAP_MS = 2 * EDIT_RATE_WINDOW_MS / 10;
20414
+ /** What makes a card the same card across messages: a new message may reuse a card's id, and the newest stands for it. */
20415
+ var cardKey = (message) => message.card && `${message.card.kind}\n${message.card.id}`;
20883
20416
  var EditQueue = class {
20884
20417
  deps;
20885
20418
  pace;
@@ -20887,6 +20420,8 @@ var EditQueue = class {
20887
20420
  now;
20888
20421
  /** Per message: the edit number last sent, when, and how many times that number went. */
20889
20422
  sent = /* @__PURE__ */ new Map();
20423
+ /** When a card that goes again last went (`CARD_RESTORE_GAP_MS`). */
20424
+ restoredAt = -Infinity;
20890
20425
  timer;
20891
20426
  flushing = null;
20892
20427
  again = false;
@@ -20953,7 +20488,14 @@ var EditQueue = class {
20953
20488
  if (this.stopped) return;
20954
20489
  const now = this.now();
20955
20490
  const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
20956
- const due = (await this.deps.read()).filter((m) => m.sender === "me" && m.edit?.pending && m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
20491
+ const rows = await this.deps.read();
20492
+ const newest = /* @__PURE__ */ new Map();
20493
+ for (const m of rows) {
20494
+ const key = m.sender === "me" && cardKey(m);
20495
+ if (key && m.timestamp > (newest.get(key) ?? -Infinity)) newest.set(key, m.timestamp);
20496
+ }
20497
+ const due = rows.filter((m) => m.sender === "me" && m.edit?.pending && m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
20498
+ const gap = this.deps.restoreGapMs ?? CARD_RESTORE_GAP_MS;
20957
20499
  let next = Infinity;
20958
20500
  for (const message of due) {
20959
20501
  const edit = message.edit;
@@ -20962,6 +20504,11 @@ var EditQueue = class {
20962
20504
  await this.deps.settle(message.id, edit.seq);
20963
20505
  continue;
20964
20506
  }
20507
+ if (edit.restore && message.timestamp < (newest.get(cardKey(message) || "") ?? -Infinity)) {
20508
+ this.sent.delete(message.id);
20509
+ await this.deps.settle(message.id, edit.seq);
20510
+ continue;
20511
+ }
20965
20512
  if (message.delivery === "waiting") continue;
20966
20513
  if (!this.deps.ready()) return;
20967
20514
  const last = this.sent.get(message.id);
@@ -20974,6 +20521,10 @@ var EditQueue = class {
20974
20521
  continue;
20975
20522
  }
20976
20523
  }
20524
+ if (edit.restore && this.restoredAt + gap > now) {
20525
+ next = Math.min(next, this.restoredAt + gap);
20526
+ continue;
20527
+ }
20977
20528
  const pace = this.pace.wait();
20978
20529
  if (pace > 0) {
20979
20530
  next = Math.min(next, now + pace);
@@ -20989,6 +20540,7 @@ var EditQueue = class {
20989
20540
  ...message.preview && { pv: message.preview },
20990
20541
  ...message.card && { sc: message.card }
20991
20542
  }, message)) continue;
20543
+ if (edit.restore) this.restoredAt = now;
20992
20544
  this.sent.set(message.id, {
20993
20545
  seq: edit.seq,
20994
20546
  at: now,
@@ -21598,18 +21150,35 @@ var db = {
21598
21150
  },
21599
21151
  async deleteLink(linkId) {
21600
21152
  await wrap((await store(STORES$1.links, "readwrite")).delete(linkId));
21601
- const messages = await store(STORES$1.messages, "readwrite");
21602
- const keys = await wrap(messages.index("byLink").getAllKeys(linkId));
21603
- await Promise.all(keys.map((key) => wrap(messages.delete(key))));
21153
+ await wrap((await store(STORES$1.messages, "readwrite")).delete(IDBKeyRange.bound([linkId], [linkId, []])));
21604
21154
  await fileStore.deleteForLink(linkId);
21605
21155
  await removeFileBytes(`${linkId}-in-`);
21606
21156
  await removeFileBytes(`${linkId}-out-`);
21607
21157
  const appStorage = await store(STORES$1.appStorage, "readwrite");
21608
- const appKeys = await wrap(appStorage.index(APP_STORAGE_CHAT_INDEX).getAllKeys(linkId));
21609
- await Promise.all(appKeys.map((key) => wrap(appStorage.delete(key))));
21158
+ await new Promise((resolve, reject) => {
21159
+ appStorage.transaction.oncomplete = () => resolve();
21160
+ appStorage.transaction.onerror = appStorage.transaction.onabort = () => reject(appStorage.transaction.error ?? /* @__PURE__ */ new Error("App storage failed"));
21161
+ const request = appStorage.index(APP_STORAGE_CHAT_INDEX).openKeyCursor(IDBKeyRange.only(linkId));
21162
+ request.onsuccess = () => {
21163
+ const cursor = request.result;
21164
+ if (!cursor) return;
21165
+ const [ref] = cursor.primaryKey;
21166
+ appStorage.delete(IDBKeyRange.bound([ref, linkId], [
21167
+ ref,
21168
+ linkId,
21169
+ []
21170
+ ]));
21171
+ cursor.continue();
21172
+ };
21173
+ });
21610
21174
  },
21175
+ /**
21176
+ * A chat's whole history, by time. Read by its key range (every key is `[linkId, id]`, ids are strings: from `[linkId]`
21177
+ * to `[linkId, []]`), the same rows in the same order as the `byLink` index has them, without looking each one up
21178
+ * again: in the CLI's store (fake-indexeddb) that lookup cost 4-9 times the read itself.
21179
+ */
21611
21180
  async getMessages(linkId) {
21612
- return (await wrap((await store(STORES$1.messages, "readonly")).index("byLink").getAll(linkId))).sort((a, b) => a.timestamp - b.timestamp);
21181
+ return (await wrap((await store(STORES$1.messages, "readonly")).getAll(IDBKeyRange.bound([linkId], [linkId, []])))).sort((a, b) => a.timestamp - b.timestamp);
21613
21182
  },
21614
21183
  /** Whether the chat has a message with this id. */
21615
21184
  async hasMessage(linkId, id) {
@@ -21806,9 +21375,10 @@ function noteAfterChange(current, message) {
21806
21375
  };
21807
21376
  }
21808
21377
  /**
21809
- * My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`, of
21810
- * this membership only. One made before I was removed and invited again went out under my old member key; said again
21811
- * now it would go under the new one, and every member would show it twice.
21378
+ * My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`,
21379
+ * newest first (what the member is likeliest to have missed lands before its pace runs out), of this membership only.
21380
+ * One made before I was removed and invited again went out under my old member key; said again now it would go under
21381
+ * the new one, and every member would show it twice.
21812
21382
  */
21813
21383
  function groupReactionsToResend(messages, limit) {
21814
21384
  let joinedAt = 0;
@@ -21820,7 +21390,7 @@ function groupReactionsToResend(messages, limit) {
21820
21390
  e: r.e,
21821
21391
  n: r.n
21822
21392
  }] : [];
21823
- }).sort((a, b) => b.n - a.n).slice(0, limit).reverse();
21393
+ }).sort((a, b) => b.n - a.n).slice(0, limit);
21824
21394
  }
21825
21395
  /** This side's highest number in a chat: the next one goes past it (and past what still waits to be confirmed). */
21826
21396
  function lastMine(messages, pending = []) {
@@ -21831,7 +21401,7 @@ function lastMine(messages, pending = []) {
21831
21401
  }
21832
21402
  var Reactions = class {
21833
21403
  host;
21834
- /** Reactions to messages not here yet, per chat, until `REACTION_LIMITS.bufferMs` after they came. */
21404
+ /** Reactions to messages not here yet, per chat, until `REACTION_LIMITS.bufferMs` after they came (`buffer` per sender). */
21835
21405
  waiting = /* @__PURE__ */ new Map();
21836
21406
  /** One chat's reactions at a time: a burst for one message lands in order. */
21837
21407
  queue = Promise.resolve();
@@ -21877,7 +21447,7 @@ var Reactions = class {
21877
21447
  }
21878
21448
  /**
21879
21449
  * A reaction that came from `by` (`peer`, or a member's key), already authenticated as theirs. A message not here
21880
- * yet keeps it for a minute; past the room for that, it is dropped (and, on a 1:1 session, not confirmed).
21450
+ * yet keeps it for five minutes; past the room for that sender, it is dropped (and, on a 1:1 session, not confirmed).
21881
21451
  */
21882
21452
  receive(chat, by, reaction) {
21883
21453
  if (isGroup$1(chat)) {
@@ -21904,21 +21474,22 @@ var Reactions = class {
21904
21474
  const now = this.now(), all = (this.waiting.get(message.linkId) ?? []).filter((w) => w.until > now);
21905
21475
  const mine = all.filter((w) => w.reaction.id === id).sort((a, b) => a.reaction.n - b.reaction.n);
21906
21476
  this.waiting.set(message.linkId, all.filter((w) => w.reaction.id !== id));
21907
- for (const { by, reaction } of mine) {
21908
- const target = reactionTarget(await this.host.messages(message.linkId), id, isGroup$1(message.linkId));
21909
- if (target && reactionIsNewer(target.reactions?.[by], reaction.n)) await this.write(message.linkId, target, by, reaction);
21910
- }
21477
+ for (const { by, reaction } of mine) await this.write(message.linkId, message, by, reaction);
21911
21478
  });
21912
21479
  }
21913
21480
  /**
21914
21481
  * The row a received reaction names. Most often the row of that id (a group's), or of `peer_` or `me_` and that id (a
21915
- * chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat.
21482
+ * chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat;
21483
+ * in a group there is no other, since a row that takes a reaction goes by its own id there.
21916
21484
  */
21917
21485
  async find(chat, id) {
21918
21486
  const group = isGroup$1(chat);
21919
- if (this.host.message) for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21920
- const row = await this.host.message(chat, rowId);
21921
- if (row && replyRef(row, group) === id) return row;
21487
+ if (this.host.message) {
21488
+ for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21489
+ const row = await this.host.message(chat, rowId);
21490
+ if (row && replyRef(row, group) === id) return row;
21491
+ }
21492
+ if (group) return void 0;
21922
21493
  }
21923
21494
  return reactionTarget(await this.host.messages(chat), id, group);
21924
21495
  }
@@ -21933,7 +21504,7 @@ var Reactions = class {
21933
21504
  if (same && same.reaction.n >= reaction.n) return "stale";
21934
21505
  const kept = list.filter((w) => w !== same);
21935
21506
  this.waiting.set(chat, kept);
21936
- if (kept.length >= REACTION_LIMITS.buffer) return "dropped";
21507
+ if (kept.filter((w) => w.by === by).length >= REACTION_LIMITS.buffer) return "dropped";
21937
21508
  kept.push({
21938
21509
  by,
21939
21510
  reaction,
@@ -22099,11 +21670,14 @@ var HubClocks = class {
22099
21670
  };
22100
21671
  //#endregion
22101
21672
  //#region ../browser/src/engine/community.ts
22102
- /** The line a change of a group's picture leaves in its history (both profiles). */
22103
- var pictureText = (name, set) => `${name} ${set ? "changed" : "removed"} the group's picture`;
21673
+ /** The line a change of a group's picture leaves in its history (both profiles); without a name, nobody is credited. */
21674
+ var pictureText = (name, set) => name === void 0 ? `The group's picture was ${set ? "changed" : "removed"}` : `${name} ${set ? "changed" : "removed"} the group's picture`;
22104
21675
  /** The line a new name leaves (both profiles): `group` is the name the group has now. */
22105
- var renameText = (name, group) => `${name} renamed the group to “${group}”`;
22106
- /** The lines a statement that changed the group's name or picture leaves in its history, in that order. */
21676
+ var renameText = (name, group) => name === void 0 ? `The group is now called “${group}”` : `${name} renamed the group to “${group}”`;
21677
+ /**
21678
+ * The lines a statement that changed the group's name or picture leaves in its history, in that order. No `name` when
21679
+ * the core cannot say who made the change (a new admin signed again what the last one set).
21680
+ */
22107
21681
  function metaLines(name, change, group) {
22108
21682
  return [..."name" in change ? [{
22109
21683
  event: "renamed",
@@ -22493,7 +22067,8 @@ var Communities = class {
22493
22067
  await this.store.putGroup(group);
22494
22068
  this.host.emit();
22495
22069
  }
22496
- async send(groupId, text, mentions = [], reply, forwarded, card, file) {
22070
+ /** Whether a message of mine could go to the group now: the refusal `send` would answer for not being in it, or null. */
22071
+ sendCheck(groupId) {
22497
22072
  const live = this.live.get(groupId);
22498
22073
  if (!live) return {
22499
22074
  error: "You are not in this group yet",
@@ -22503,6 +22078,12 @@ var Communities = class {
22503
22078
  error: live.session.state.statusReason ?? "You are not in this group",
22504
22079
  refused: true
22505
22080
  };
22081
+ return { error: null };
22082
+ }
22083
+ async send(groupId, text, mentions = [], reply, forwarded, card, file) {
22084
+ const check = this.sendCheck(groupId);
22085
+ if (check.error) return check;
22086
+ const live = this.live.get(groupId);
22506
22087
  const now = this.now();
22507
22088
  const result = card || file ? await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded, card, file) : await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded);
22508
22089
  return "error" in result ? { error: result.error } : {
@@ -22632,6 +22213,7 @@ var Communities = class {
22632
22213
  this.knockRead.delete(groupId);
22633
22214
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
22634
22215
  await this.store.deleteGroup(groupId);
22216
+ await this.files?.drop(groupId);
22635
22217
  this.host.historyGone?.(groupId);
22636
22218
  this.host.emit();
22637
22219
  }
@@ -23385,6 +22967,16 @@ var Communities = class {
23385
22967
  }
23386
22968
  }
23387
22969
  /**
22970
+ * Let in: my knock leaves the record I knocked in. It stayed there until it aged out, and someone opening the link in
22971
+ * the next minute read it as a person still waiting to be let in. Once, in the background; if it fails, it ages out.
22972
+ */
22973
+ async unknock(link, n, seedB64) {
22974
+ const me = identityFromSeedB64(seedB64).pubKeyZ32, record = knockRecord(link, n);
22975
+ const knocks = readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true) ?? []);
22976
+ if (!knocks.some((k) => k.key === me)) return;
22977
+ await this.host.publish(knockIdentity(record), knockRecords(record, knocks.filter((k) => k.key !== me)), true);
22978
+ }
22979
+ /**
23388
22980
  * The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
23389
22981
  * link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
23390
22982
  * its next tick (it stops being a hub and a door there), and knocked for good with nothing for a member to answer on.
@@ -23507,6 +23099,10 @@ var Communities = class {
23507
23099
  await this.store.putGroup(member);
23508
23100
  if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
23509
23101
  else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
23102
+ if (this.knocked.has(g)) this.unknock({
23103
+ g,
23104
+ host: joining.host
23105
+ }, this.knockAt.get(g) ?? KNOCK_BELL, joining.seedB64).catch(() => {});
23510
23106
  this.lastKnock.delete(g);
23511
23107
  this.knocked.delete(g);
23512
23108
  this.knockAt.delete(g);
@@ -23654,7 +23250,7 @@ var Communities = class {
23654
23250
  this.membershipChanged(id);
23655
23251
  },
23656
23252
  metaChanged: (by, change, at) => {
23657
- const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
23253
+ const name = by === void 0 ? void 0 : by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
23658
23254
  const when = receivedTimestamp(at, this.now());
23659
23255
  (async () => {
23660
23256
  for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
@@ -23851,7 +23447,8 @@ var MeshHubs = class {
23851
23447
  primed: false,
23852
23448
  gone: /* @__PURE__ */ new Map(),
23853
23449
  reactions: /* @__PURE__ */ new Map(),
23854
- need: 0
23450
+ need: 0,
23451
+ departed: /* @__PURE__ */ new Set()
23855
23452
  });
23856
23453
  }
23857
23454
  return live;
@@ -23903,10 +23500,36 @@ var MeshHubs = class {
23903
23500
  const live = this.live.get(groupId);
23904
23501
  if (!live || !this.large(session)) return [];
23905
23502
  const policy = session.hubPolicy, inRoster = (key) => rosterHas(session.roster, key);
23906
- const out = new Set(meshHubs(live.beacon, session.myKey, policy, inRoster, now).map((h) => h.key));
23907
- for (const [key, at] of live.seenHubs) if (now - at <= this.timings.graceMs && key !== session.myKey && inRoster(key) && !policy.no.includes(key)) out.add(key);
23503
+ const out = new Set(this.listed(live, session, now).map((h) => h.key));
23504
+ for (const [key, at] of live.seenHubs) if (now - at <= this.timings.graceMs && key !== session.myKey && inRoster(key) && !policy.no.includes(key) && !live.departed.has(key)) out.add(key);
23908
23505
  return [...out];
23909
23506
  }
23507
+ /** The beacon's hubs a member counts on (`meshHubs`), but those that said they leave. */
23508
+ listed(live, session, now) {
23509
+ return meshHubs(live.beacon, session.myKey, session.hubPolicy, (key) => rosterHas(session.roster, key), now).filter((h) => !live.departed.has(h.key));
23510
+ }
23511
+ /**
23512
+ * A member said on its own edge that it leaves: if it was a hub, it is none from now on, rather than until the beacon
23513
+ * forgets it and its grace runs out (the commit that takes it out may have no other way here). True when a hub went.
23514
+ */
23515
+ departs(groupId, key) {
23516
+ const live = this.live.get(groupId);
23517
+ if (!live || live.departed.has(key)) return false;
23518
+ live.departed.add(key);
23519
+ const was = live.seenHubs.delete(key) || live.myHubs.includes(key) || live.beacon.some((h) => h.key === key);
23520
+ live.reach.delete(key);
23521
+ live.hubsUp.delete(key);
23522
+ live.myHubs = live.myHubs.filter((k) => k !== key);
23523
+ return was;
23524
+ }
23525
+ /** As a hub that leaves the group: out of the beacon on my way, while the session can still write it. */
23526
+ async stepDown(groupId, session, now) {
23527
+ const live = this.live.get(groupId);
23528
+ if (!live?.hub) return;
23529
+ live.hub = false;
23530
+ live.members.clear();
23531
+ await this.publish(groupId, session, live, now, false).catch(() => {});
23532
+ }
23910
23533
  /** The group runs on hubs: it is large, and I am a hub or know of one. Else it is the full mesh. */
23911
23534
  active(groupId, session, now) {
23912
23535
  return this.large(session) && (this.isHub(groupId) || this.hubs(groupId, session, now).length > 0);
@@ -24003,7 +23626,7 @@ var MeshHubs = class {
24003
23626
  const may = mayBeHub(me, policy, inRoster, this.staysOnline);
24004
23627
  const readEvery = live.hub || may ? this.timings.beaconEveryMs : this.timings.beaconReadMs;
24005
23628
  if (!live.lastRead || now - live.lastRead >= readEvery) await this.read(groupId, session, live, now);
24006
- const others = meshHubs(live.beacon, me, policy, inRoster, now);
23629
+ const others = this.listed(live, session, now);
24007
23630
  const fits = this.fits(groupId, session);
24008
23631
  if (live.hub) live.need = session.others.length;
24009
23632
  if (!live.hub && may && fits && (policy.pin.includes(me) || others.length < MESH_HUBS.maxHubs)) {
@@ -24044,8 +23667,9 @@ var MeshHubs = class {
24044
23667
  this.noteHubs(live, session, now);
24045
23668
  }
24046
23669
  noteHubs(live, session, now) {
24047
- for (const h of meshHubs(live.beacon, session.myKey, session.hubPolicy, (key) => rosterHas(session.roster, key), now)) live.seenHubs.set(h.key, now);
23670
+ for (const h of this.listed(live, session, now)) live.seenHubs.set(h.key, now);
24048
23671
  for (const [key, at] of live.seenHubs) if (now - at > this.timings.graceMs || !rosterHas(session.roster, key)) live.seenHubs.delete(key);
23672
+ for (const key of live.departed) if (!rosterHas(session.roster, key)) live.departed.delete(key);
24049
23673
  }
24050
23674
  /** My entry in the beacon (or none, stepping down), merged with the other fresh hubs I do not drop. */
24051
23675
  async publish(groupId, session, live, now, listed) {
@@ -24130,7 +23754,7 @@ var MeshHubs = class {
24130
23754
  live.hubWaits.delete(key);
24131
23755
  }
24132
23756
  }
24133
- const fresh = meshHubs(live.beacon, me, session.hubPolicy, (key) => rosterHas(session.roster, key), now);
23757
+ const fresh = this.listed(live, session, now);
24134
23758
  const candidates = [...fresh, ...[...hubs].filter((key) => !fresh.some((h) => h.key === key)).map((key) => ({
24135
23759
  key,
24136
23760
  ts: 0,
@@ -24246,7 +23870,7 @@ var MeshHubs = class {
24246
23870
  if (!rosterHas(session.roster, from) || !Array.isArray(frame.k) || frame.k.length > session.roster.length) return false;
24247
23871
  const live = this.get(groupId, now);
24248
23872
  live.reach.set(from, new Set(frame.k.filter((key) => typeof key === "string" && rosterHas(session.roster, key))));
24249
- if (!session.hubPolicy.no.includes(from) && from !== session.myKey) live.seenHubs.set(from, now);
23873
+ if (!session.hubPolicy.no.includes(from) && from !== session.myKey && !live.departed.has(from)) live.seenHubs.set(from, now);
24250
23874
  if (!Array.isArray(frame.l) || frame.l.length > session.roster.length) return false;
24251
23875
  const legacy = new Set(group.legacy ?? []);
24252
23876
  let changed = false;
@@ -24726,6 +24350,22 @@ var ENTRY_RELEASE_MS = 2e3;
24726
24350
  var REMOVED_LINGER_MS = 15e3;
24727
24351
  /** How long the tombstone of a group I left waits for the admin to hear it. */
24728
24352
  var LEFT_KEPT_MS = 6048e5;
24353
+ /**
24354
+ * Invitee side: an accept the admin has not answered with a welcome is said again this often while the inviter's chat
24355
+ * is up, and at once when it comes up, at most `ACCEPT_AGAINS` times each time it does (its app may have restarted).
24356
+ */
24357
+ var ACCEPT_AGAIN_MS = 6e4;
24358
+ var ACCEPT_AGAINS = 5;
24359
+ /** Invitee side: the invitations one contact chat may have waiting here at a time. More are answered as declined. */
24360
+ var MAX_INVITATIONS_PER_CHAT = 4;
24361
+ /** What an invitation says of its group, kept as a claim within what a group can be: an epoch from 0, a roster of 1 to the most a group holds. */
24362
+ function invitationClaims(frame) {
24363
+ const e = Number.isSafeInteger(frame.e) ? frame.e : 0, n = Number.isSafeInteger(frame.n) ? frame.n : 1;
24364
+ return {
24365
+ e: Math.max(e, 0),
24366
+ n: Math.min(Math.max(n, 1), GROUP_MEMBER_CAP.max)
24367
+ };
24368
+ }
24729
24369
  /** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
24730
24370
  var FAREWELL_ANSWERS = 3;
24731
24371
  /** Commits sent to a removed member that was behind when it left, at most. */
@@ -24787,8 +24427,10 @@ var Groups = class {
24787
24427
  timings;
24788
24428
  stored = /* @__PURE__ */ new Map();
24789
24429
  sessions = /* @__PURE__ */ new Map();
24790
- /** Contacts (chat ids) I invited to each group, until they answer or the app restarts. */
24430
+ /** Contacts (chat ids, kept in `StoredGroup.invited`) and entry sessions I invited to each group, until they answer. */
24791
24431
  invited = /* @__PURE__ */ new Map();
24432
+ /** Invitee side: when my accept of each invitation was last said, how many times since the inviter's chat came up, and whether it was down. */
24433
+ acceptSaid = /* @__PURE__ */ new Map();
24792
24434
  lastRoster = /* @__PURE__ */ new Map();
24793
24435
  /** Each group's status and newest commit as its history last said them: a line per change of either, never one again. */
24794
24436
  lastTold = /* @__PURE__ */ new Map();
@@ -24831,6 +24473,14 @@ var Groups = class {
24831
24473
  */
24832
24474
  relayAsked = /* @__PURE__ */ new Map();
24833
24475
  lastGossip = /* @__PURE__ */ new Map();
24476
+ /**
24477
+ * Per mesh group, each reachable member some of whose messages never reached me (`GroupSession.lacking`): what was
24478
+ * missing when it was asked for, and of whom since. Each connected member once until that changes, so messages nobody
24479
+ * holds any more are not asked for every gossip turn. `last`: the latest ask, its member and the numbers it named.
24480
+ */
24481
+ gapsAsked = /* @__PURE__ */ new Map();
24482
+ /** Per mesh group, when `gapsAgain` last looked: every half `MESH_GAP_AGAIN_MS`, not every tick. */
24483
+ gapsLooked = /* @__PURE__ */ new Map();
24834
24484
  /** Per mesh group: member key → since when its edge to me is down, and when someone last told me it is here. */
24835
24485
  downSince = /* @__PURE__ */ new Map();
24836
24486
  hereHeard = /* @__PURE__ */ new Map();
@@ -24899,12 +24549,14 @@ var Groups = class {
24899
24549
  this.communities.files = this.files;
24900
24550
  }
24901
24551
  }
24902
- async load() {
24552
+ /** `asItIs`: a copy that must stay as it was stored (limited mode, WISP 06), so nothing left over is cleared from it. */
24553
+ async load(asItIs = false) {
24903
24554
  const all = await this.store.getGroups();
24904
24555
  await this.communities.load(all.filter((g) => g.community || g.joining));
24905
24556
  for (const group of all.filter((g) => !g.community && !g.joining)) {
24906
24557
  this.stored.set(group.id, group);
24907
24558
  if (group.state) this.attach(group.state);
24559
+ if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
24908
24560
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
24909
24561
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
24910
24562
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
@@ -24916,6 +24568,7 @@ var Groups = class {
24916
24568
  }
24917
24569
  for (const id of this.sessions.keys()) this.reconcileEdges(id);
24918
24570
  await this.files?.load(all.map((g) => g.id));
24571
+ if (!asItIs) await this.files?.sweep(all.map((g) => g.id));
24919
24572
  for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
24920
24573
  }
24921
24574
  /** A group's pin now (WISP 400 § Pinned message), the latest one taken, unpinned or not. */
@@ -24971,7 +24624,7 @@ var Groups = class {
24971
24624
  linkId: invitation.linkId,
24972
24625
  contact: this.host.contactName(invitation.linkId) ?? "",
24973
24626
  admin: invitation.admin,
24974
- members: invitation.n,
24627
+ members: Math.min(Math.max(invitation.n, 0), GROUP_MEMBER_CAP.max),
24975
24628
  accepted: !!invitation.seedB64,
24976
24629
  ...invitation.entry ? {
24977
24630
  viaLink: true,
@@ -25112,8 +24765,21 @@ var Groups = class {
25112
24765
  let set = this.invited.get(groupId);
25113
24766
  if (!set) this.invited.set(groupId, set = /* @__PURE__ */ new Set());
25114
24767
  set.add(linkId);
24768
+ group.invited = {
24769
+ ...group.invited,
24770
+ [linkId]: Date.now()
24771
+ };
24772
+ await this.store.putGroup(group);
25115
24773
  this.host.emit();
25116
24774
  }
24775
+ /** Admin side: a contact chat answered its invitation (accepted or declined); false when it had none. */
24776
+ answered(group, linkId) {
24777
+ const had = !!this.invited.get(group.id)?.delete(linkId);
24778
+ if (!group.invited?.[linkId]) return had;
24779
+ const { [linkId]: _answered, ...rest } = group.invited;
24780
+ group.invited = Object.keys(rest).length ? rest : void 0;
24781
+ return true;
24782
+ }
25117
24783
  async accept(groupId) {
25118
24784
  const group = this.stored.get(groupId);
25119
24785
  if (!group?.invitation) throw new Error("No invitation to accept");
@@ -25130,6 +24796,10 @@ var Groups = class {
25130
24796
  g: groupId,
25131
24797
  key: identityFromSeedB64(seedB64).pubKeyZ32
25132
24798
  });
24799
+ this.acceptSaid.set(groupId, {
24800
+ at: this.now(),
24801
+ n: 0
24802
+ });
25133
24803
  this.host.emit();
25134
24804
  }
25135
24805
  async decline(groupId) {
@@ -25142,7 +24812,12 @@ var Groups = class {
25142
24812
  g: groupId
25143
24813
  });
25144
24814
  } catch {}
25145
- await this.forget(groupId);
24815
+ const previous = group.previous;
24816
+ if (!previous) return this.forget(groupId);
24817
+ this.stored.set(groupId, previous);
24818
+ if (previous.state) this.attach(previous.state);
24819
+ await this.store.putGroup(previous);
24820
+ this.host.emit();
25146
24821
  }
25147
24822
  /**
25148
24823
  * Sends a text; `messageId` is the id it is kept under here (what history, replies and reactions name). `forwarded`:
@@ -25160,6 +24835,23 @@ var Groups = class {
25160
24835
  };
25161
24836
  }
25162
24837
  /**
24838
+ * Whether a file of mine could be announced in the group now, asked before its bytes are copied in: the error
24839
+ * `sendFile` would answer for the pace or for not being in the group, or null. `sendFile` checks again.
24840
+ */
24841
+ fileCheck(groupId) {
24842
+ if (!this.files) return {
24843
+ error: "This app takes no group files",
24844
+ refused: true
24845
+ };
24846
+ const paced = this.files.mayAnnounce(groupId);
24847
+ if (paced) return { error: paced };
24848
+ if (this.isCommunity(groupId)) return this.communities.sendCheck(groupId);
24849
+ const session = this.sessions.get(groupId);
24850
+ if (!session) return { error: "You are not in this group yet" };
24851
+ if (session.status !== "active") return { error: session.state.statusReason ?? "You are no longer in this group" };
24852
+ return { error: null };
24853
+ }
24854
+ /**
25163
24855
  * Announces a file or voice message of mine (WISP 503): `text` is its caption, or the line older apps show
25164
24856
  * (`groupFileFallback`), `file` its description with the digest of the bytes kept under `fileId`, which this device
25165
24857
  * then holds and serves. `messageId` is the announcement's.
@@ -25238,8 +24930,8 @@ var Groups = class {
25238
24930
  });
25239
24931
  }
25240
24932
  /**
25241
- * Leaves, and the group is gone from this device at once: its row, its history and every edge
25242
- * but the one to the admin, which stays until the admin's commit removing me comes back (or a
24933
+ * Leaves, and the group is gone from this device at once: its row, its history, its files and every
24934
+ * edge but the one to the admin, which stays until the admin's commit removing me comes back (or a
25243
24935
  * week passes), so a leave said while the admin was away still reaches it. An admin with other
25244
24936
  * members hands the role to one who is online first; alone, the group simply goes.
25245
24937
  */
@@ -25268,6 +24960,7 @@ var Groups = class {
25268
24960
  ...bye ? { bye } : {}
25269
24961
  };
25270
24962
  delete group.entry;
24963
+ delete group.invited;
25271
24964
  this.invited.delete(groupId);
25272
24965
  this.pendingEntries.delete(groupId);
25273
24966
  this.welcomed.delete(groupId);
@@ -25277,9 +24970,18 @@ var Groups = class {
25277
24970
  this.lastMentionAt.delete(groupId);
25278
24971
  await this.store.deleteGroup(groupId);
25279
24972
  await this.store.putGroup(group);
24973
+ await this.files?.drop(groupId);
25280
24974
  this.host.historyGone?.(groupId);
25281
24975
  for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
25282
24976
  this.host.emit();
24977
+ if (this.hubs.isHub(groupId)) {
24978
+ await this.hubs.stepDown(groupId, session, this.now());
24979
+ if (bye) {
24980
+ for (const [key, edge] of this.host.edges(groupId)) if (!hubs.includes(key) && this.host.linkReady(edge)) try {
24981
+ this.host.sendOnLink(edge, bye);
24982
+ } catch {}
24983
+ }
24984
+ }
25283
24985
  await session.leave();
25284
24986
  if (bye) for (const key of hubs) {
25285
24987
  const edge = this.host.edges(groupId).get(key);
@@ -25452,17 +25154,17 @@ var Groups = class {
25452
25154
  await this.session(groupId).rename(name);
25453
25155
  }
25454
25156
  async forget(groupId) {
25455
- if (this.isCommunity(groupId)) {
25456
- await this.communities.forget(groupId);
25457
- await this.files?.drop(groupId);
25458
- return;
25459
- }
25460
- if (this.sessions.get(groupId)?.status === "active") try {
25157
+ if (this.isCommunity(groupId)) return this.communities.leave(groupId);
25158
+ const session = this.sessions.get(groupId);
25159
+ if (session?.status === "active") try {
25461
25160
  await this.leave(groupId);
25462
- } catch {}
25161
+ } catch (e) {
25162
+ if (session.isAdmin && session.others.length) throw e;
25163
+ }
25463
25164
  this.sessions.delete(groupId);
25464
25165
  this.stored.delete(groupId);
25465
25166
  this.invited.delete(groupId);
25167
+ this.acceptSaid.delete(groupId);
25466
25168
  this.lastRoster.delete(groupId);
25467
25169
  this.lastTold.delete(groupId);
25468
25170
  this.pendingEntries.delete(groupId);
@@ -25476,6 +25178,8 @@ var Groups = class {
25476
25178
  this.hubs.forget(groupId);
25477
25179
  this.removedAt.delete(groupId);
25478
25180
  this.lastGossip.delete(groupId);
25181
+ this.gapsAsked.delete(groupId);
25182
+ this.gapsLooked.delete(groupId);
25479
25183
  this.typings.forget(groupId);
25480
25184
  this.downSince.delete(groupId);
25481
25185
  this.hereHeard.delete(groupId);
@@ -25530,8 +25234,7 @@ var Groups = class {
25530
25234
  */
25531
25235
  async joinByLink(code) {
25532
25236
  if (decodeCommunityLink(code)) {
25533
- const g = decodeCommunityLink(code).g;
25534
- if (this.stored.has(g)) await this.forget(g);
25237
+ if (this.stored.has(decodeCommunityLink(code).g)) throw new Error("This link names a private group of yours, not a community");
25535
25238
  return this.communities.joinByLink(code);
25536
25239
  }
25537
25240
  const link = decodeGroupEntryLink(code);
@@ -25540,6 +25243,8 @@ var Groups = class {
25540
25243
  const existing = this.stored.get(link.g);
25541
25244
  if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
25542
25245
  if (existing?.invitation?.seedB64 && !existing.invitation.entry) throw new Error("You are already joining this group");
25246
+ if (existing?.invitation && !existing.invitation.entry) throw new Error("You have an invitation to this group. Answer it first.");
25247
+ if (existing?.state && !existing.left) throw new Error("You are out of this group and its history is still here. Delete it from this device to join with a link.");
25543
25248
  if (existing) await this.forget(link.g);
25544
25249
  const seedB64 = dialedKey(link);
25545
25250
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
@@ -25579,6 +25284,10 @@ var Groups = class {
25579
25284
  if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && (now - (this.lastKnock.get(group.id) ?? 0) >= every || now >= (this.knockCheckAt.get(group.id) ?? Infinity))) await this.knock(group, now).catch(() => {});
25580
25285
  continue;
25581
25286
  }
25287
+ if (group.invitation?.seedB64 && !group.state) {
25288
+ this.acceptAgain(group, now);
25289
+ continue;
25290
+ }
25582
25291
  const session = this.sessions.get(group.id);
25583
25292
  if (!group.entry || !session) continue;
25584
25293
  if (!session.isAdmin) {
@@ -25625,6 +25334,7 @@ var Groups = class {
25625
25334
  }
25626
25335
  for (const [groupId, session] of this.sessions) {
25627
25336
  const group = this.stored.get(groupId);
25337
+ if (session.status !== "active" && !this.removedAt.has(groupId) && this.hubs.isHub(groupId)) this.hubs.forget(groupId);
25628
25338
  if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
25629
25339
  await this.hubs.tick(groupId, session, group, now).catch(() => false);
25630
25340
  if (this.hubReconcile.has(groupId)) continue;
@@ -25636,6 +25346,39 @@ var Groups = class {
25636
25346
  }
25637
25347
  }
25638
25348
  }
25349
+ /**
25350
+ * Invitee side: my accept, not answered with a welcome yet, goes again over the inviter's chat (`ACCEPT_AGAIN_MS`):
25351
+ * the admin's app may have been closed or started again since, and an accept it never read waits for good otherwise.
25352
+ * An admin that let me in already, or never invited me, ignores it.
25353
+ */
25354
+ acceptAgain(group, now) {
25355
+ const invitation = group.invitation, said = this.acceptSaid.get(group.id) ?? {
25356
+ at: 0,
25357
+ n: 0
25358
+ };
25359
+ if (!this.host.linkReady(invitation.linkId)) {
25360
+ if (!said.down) this.acceptSaid.set(group.id, {
25361
+ ...said,
25362
+ n: 0,
25363
+ down: true
25364
+ });
25365
+ return;
25366
+ }
25367
+ if (said.n >= ACCEPT_AGAINS || !said.down && now - said.at < ACCEPT_AGAIN_MS) return;
25368
+ try {
25369
+ this.host.sendOnLink(invitation.linkId, {
25370
+ t: "group-accept",
25371
+ g: group.id,
25372
+ key: identityFromSeedB64(invitation.seedB64).pubKeyZ32
25373
+ });
25374
+ } catch {
25375
+ return;
25376
+ }
25377
+ this.acceptSaid.set(group.id, {
25378
+ at: now,
25379
+ n: said.n + 1
25380
+ });
25381
+ }
25639
25382
  /** An entry session came up with groups on both sides: the admin's side invites over it. */
25640
25383
  entryReady(groupId, linkId, peer) {
25641
25384
  if (this.isCommunity(groupId)) {
@@ -25806,8 +25549,7 @@ var Groups = class {
25806
25549
  name: groupName(frame.name) ?? "Group",
25807
25550
  admin: frame.admin,
25808
25551
  pieces: [],
25809
- e: Number.isSafeInteger(frame.e) ? frame.e : 0,
25810
- n: Number.isSafeInteger(frame.n) ? frame.n : 1
25552
+ ...invitationClaims(frame)
25811
25553
  };
25812
25554
  await this.store.putGroup(existing);
25813
25555
  traceJoin(g, "invite.received");
@@ -25819,13 +25561,29 @@ var Groups = class {
25819
25561
  this.host.emit();
25820
25562
  return;
25821
25563
  }
25822
- if (existing?.state && existing.state.status === "active") return;
25823
- if (existing?.invitation?.seedB64) return;
25564
+ if (existing?.state && existing.state.status === "active") {
25565
+ if (this.sessions.get(g)?.admin === frame.admin) this.host.sendOnLink(linkId, {
25566
+ t: "group-decline",
25567
+ g
25568
+ });
25569
+ return;
25570
+ }
25571
+ if (existing?.invitation?.seedB64 && (existing.invitation.linkId !== linkId || existing.invitation.admin !== frame.admin)) return;
25824
25572
  if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
25573
+ if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId).length >= MAX_INVITATIONS_PER_CHAT) {
25574
+ try {
25575
+ this.host.sendOnLink(linkId, {
25576
+ t: "group-decline",
25577
+ g
25578
+ });
25579
+ } catch {}
25580
+ return;
25581
+ }
25825
25582
  const known = {
25826
25583
  ...existing?.formerNames,
25827
25584
  ...existing?.state?.nicks
25828
25585
  };
25586
+ const previous = existing?.state ? existing : existing?.previous;
25829
25587
  const invitation = {
25830
25588
  id: g,
25831
25589
  createdAt: Date.now(),
@@ -25833,17 +25591,18 @@ var Groups = class {
25833
25591
  name: groupName(frame.name) ?? "Group",
25834
25592
  admin: frame.admin,
25835
25593
  linkId,
25836
- e: Number.isSafeInteger(frame.e) ? frame.e : 0,
25837
- n: Number.isSafeInteger(frame.n) ? frame.n : 1,
25594
+ ...invitationClaims(frame),
25838
25595
  pieces: []
25839
25596
  },
25840
- ...Object.keys(known).length ? { formerNames: known } : {}
25597
+ ...Object.keys(known).length ? { formerNames: known } : {},
25598
+ ...previous ? { previous } : {}
25841
25599
  };
25842
25600
  if (existing) {
25843
25601
  this.sessions.delete(g);
25844
25602
  for (const edge of this.host.edges(g).values()) await this.host.closeEdge(edge);
25845
25603
  }
25846
25604
  this.stored.set(g, invitation);
25605
+ this.acceptSaid.delete(g);
25847
25606
  await this.store.putGroup(invitation);
25848
25607
  this.host.emit();
25849
25608
  return;
@@ -25852,6 +25611,7 @@ var Groups = class {
25852
25611
  const session = this.sessions.get(g);
25853
25612
  if (!session?.isAdmin || !this.invited.get(g)?.has(linkId) || typeof frame.key !== "string" || !MEMBER_KEY.test(frame.key)) return;
25854
25613
  const group = this.stored.get(g);
25614
+ if (frame.key === session.myKey || rosterHas(session.roster, frame.key)) return;
25855
25615
  const entryPeer = this.hostEntry(g, linkId);
25856
25616
  if ([...this.host.entries(g).values()].includes(linkId) && entryPeer !== frame.key) return;
25857
25617
  if (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId)) return;
@@ -25863,8 +25623,8 @@ var Groups = class {
25863
25623
  ...group.contacts,
25864
25624
  [frame.key]: linkId
25865
25625
  };
25626
+ this.answered(group, linkId);
25866
25627
  await this.store.putGroup(group);
25867
- this.invited.get(g)?.delete(linkId);
25868
25628
  for (const piece of welcome) this.host.sendOnLink(linkId, piece);
25869
25629
  if (entryPeer) traceJoin(g, "welcome.sent");
25870
25630
  if (entryPeer) {
@@ -25888,7 +25648,10 @@ var Groups = class {
25888
25648
  this.pendingEntries.get(g)?.delete(entryPeer);
25889
25649
  await this.host.closeEdge(linkId);
25890
25650
  }
25891
- if (this.invited.get(g)?.delete(linkId)) this.host.emit();
25651
+ const group = this.stored.get(g);
25652
+ if (!group || !this.answered(group, linkId)) return;
25653
+ await this.store.putGroup(group);
25654
+ this.host.emit();
25892
25655
  return;
25893
25656
  }
25894
25657
  case "group-chain": {
@@ -25922,6 +25685,7 @@ var Groups = class {
25922
25685
  contacts: viaLink ? {} : { [group.invitation.admin]: linkId }
25923
25686
  };
25924
25687
  this.stored.set(g, member);
25688
+ this.acceptSaid.delete(g);
25925
25689
  this.attach(joined.state);
25926
25690
  if (viaLink) this.lingering.set(g, {
25927
25691
  linkId,
@@ -26017,6 +25781,7 @@ var Groups = class {
26017
25781
  return;
26018
25782
  }
26019
25783
  if (!session) return;
25784
+ if (t === "group-bye" && session.status === "active" && frame.k === peerKey && this.hubs.departs(groupId, peerKey)) this.host.emit();
26020
25785
  if (t === "group-react" || t === "group-reacted") {
26021
25786
  await this.reaction(groupId, session, peerKey, frame);
26022
25787
  return;
@@ -26107,6 +25872,9 @@ var Groups = class {
26107
25872
  } catch {
26108
25873
  return;
26109
25874
  }
25875
+ if (peerKey === session.admin) try {
25876
+ for (const bye of session.byesHeld()) this.host.sendOnLink(linkId, bye);
25877
+ } catch {}
26110
25878
  const kept = this.lingering.get(groupId);
26111
25879
  if (kept?.admin === peerKey) {
26112
25880
  if (this.signals.keptEntry(groupId) === kept.linkId) setTimeout(() => {
@@ -26222,13 +25990,123 @@ var Groups = class {
26222
25990
  });
26223
25991
  out.push(key);
26224
25992
  }
25993
+ out.push(...this.gapsOf(groupId, session, via, session.lacking()));
26225
25994
  if (out.length) this.lastGossip.set(groupId, now);
26226
25995
  return out;
26227
25996
  }
26228
25997
  /**
26229
- * While some member is unreachable, one connected member (in turn) is asked, once a `MESH_GOSSIP_MS`, for what it
26230
- * has from the unreachable ones: a message sent while the author could reach it and not me gets here within that,
26231
- * without either edge opening again. Edges the roster asks for and that do not exist are opened again.
25998
+ * Reachable members whose messages below the highest seen never reached me, for `via` to hand on: their own answer
25999
+ * comes from their last `GROUP_LIMITS.outlog` only (WISP 902 § Catch-up), the rest another member may hold. As
26000
+ * `askOf`, one member at a time, and each member once until what is missing changes.
26001
+ */
26002
+ gapsOf(groupId, session, via, lacking) {
26003
+ if (!lacking.size) return [];
26004
+ let asked = this.relayAsked.get(groupId), tried = this.gapsAsked.get(groupId);
26005
+ if (!asked) this.relayAsked.set(groupId, asked = /* @__PURE__ */ new Map());
26006
+ if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
26007
+ const edges = this.host.edges(groupId), now = this.now();
26008
+ const up = (key) => {
26009
+ const edge = edges.get(key);
26010
+ return !!edge && this.host.linkReady(edge);
26011
+ };
26012
+ for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
26013
+ const out = [];
26014
+ for (const [key, mark] of lacking) {
26015
+ if (key === via || !up(key)) continue;
26016
+ const prior = asked.get(key);
26017
+ if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4) continue;
26018
+ let entry = tried.get(key);
26019
+ if (!entry || entry.mark !== mark) tried.set(key, entry = {
26020
+ mark,
26021
+ of: /* @__PURE__ */ new Set(),
26022
+ noticed: entry?.noticed,
26023
+ last: entry?.last
26024
+ });
26025
+ if (entry.of.has(via)) continue;
26026
+ entry.of.add(via);
26027
+ entry.last = {
26028
+ via,
26029
+ at: now,
26030
+ asked: session.missOf(key)
26031
+ };
26032
+ asked.set(key, {
26033
+ via,
26034
+ at: now
26035
+ });
26036
+ out.push(key);
26037
+ }
26038
+ return out;
26039
+ }
26040
+ /**
26041
+ * Gaps asked for sooner than the gossip turn: one noticed `MESH_GAP_AGAIN_MS` ago and not asked for yet (an author's
26042
+ * own answer, its last 32, showed what is below them) goes to a connected member now; and a member that handed on some
26043
+ * of what I last asked it for likely holds the rest, so it is asked for the next numbers `MESH_GAP_AGAIN_MS` after. A
26044
+ * member back after 200 messages of one author has them in about a minute, not 32 a gossip turn. One that handed
26045
+ * nothing on waits for the gossip turn, as before.
26046
+ */
26047
+ gapsAgain(groupId, session, now, connected) {
26048
+ if (now - (this.gapsLooked.get(groupId) ?? -Infinity) < 5e3) return;
26049
+ this.gapsLooked.set(groupId, now);
26050
+ const lacking = session.lacking();
26051
+ let tried = this.gapsAsked.get(groupId);
26052
+ if (!lacking.size && !tried?.size) return;
26053
+ if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
26054
+ for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
26055
+ const edges = this.host.edges(groupId), again = /* @__PURE__ */ new Map(), fresh = /* @__PURE__ */ new Map();
26056
+ const up = (key) => {
26057
+ const edge = edges.get(key);
26058
+ return !!edge && this.host.linkReady(edge);
26059
+ };
26060
+ for (const [key, mark] of lacking) {
26061
+ let entry = tried.get(key);
26062
+ if (!entry) tried.set(key, entry = {
26063
+ mark,
26064
+ of: /* @__PURE__ */ new Set(),
26065
+ noticed: now
26066
+ });
26067
+ const { last } = entry;
26068
+ if (!up(key)) continue;
26069
+ if (!last) {
26070
+ if (now - (entry.noticed ?? now) >= 1e4) fresh.set(key, mark);
26071
+ continue;
26072
+ }
26073
+ if (now - last.at < 1e4 || !up(last.via)) continue;
26074
+ if (session.filledSince(key, last.asked)) again.set(last.via, [...again.get(last.via) ?? [], key]);
26075
+ }
26076
+ const asked = this.relayAsked.get(groupId);
26077
+ for (const [via, keys] of again) {
26078
+ for (const key of keys) {
26079
+ tried.set(key, {
26080
+ mark: lacking.get(key),
26081
+ of: /* @__PURE__ */ new Set([via]),
26082
+ last: {
26083
+ via,
26084
+ at: now,
26085
+ asked: session.missOf(key)
26086
+ }
26087
+ });
26088
+ asked?.set(key, {
26089
+ via,
26090
+ at: now
26091
+ });
26092
+ }
26093
+ try {
26094
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
26095
+ } catch {}
26096
+ }
26097
+ const others = connected.filter((key) => !fresh.has(key)), pool = others.length ? others : connected;
26098
+ if (!fresh.size || !pool.length) return;
26099
+ const via = pool[(gossipStart(session.myKey) + this.gossipTurn++) % pool.length];
26100
+ const keys = this.gapsOf(groupId, session, via, fresh);
26101
+ if (keys.length) try {
26102
+ this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
26103
+ } catch {}
26104
+ }
26105
+ /**
26106
+ * While some member is unreachable, or some of a member's messages never reached me (`gapsOf`), one connected member
26107
+ * (in turn) is asked, once a `MESH_GOSSIP_MS`, for what it has from them: a message sent while the author could reach
26108
+ * it and not me gets here within that, without either edge opening again. Edges the roster asks for and that do not
26109
+ * exist are opened again.
26232
26110
  */
26233
26111
  meshTick(now) {
26234
26112
  this.tickNow = now;
@@ -26244,10 +26122,13 @@ var Groups = class {
26244
26122
  }
26245
26123
  const away = this.unreachable(groupId, session);
26246
26124
  this.noteDown(groupId, session, away, now);
26125
+ const connected = session.others.filter((key) => !away.includes(key));
26126
+ this.gapsAgain(groupId, session, now, connected);
26247
26127
  if (now - (this.lastGossip.get(groupId) ?? -Infinity) < 6e4) continue;
26248
26128
  const edges = this.host.edges(groupId);
26249
- const connected = session.others.filter((key) => !away.includes(key));
26250
- if (!away.length || !connected.length) continue;
26129
+ if (!connected.length) continue;
26130
+ const lacking = session.lacking();
26131
+ if (!away.length && !lacking.size) continue;
26251
26132
  this.lastGossip.set(groupId, now);
26252
26133
  const via = connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
26253
26134
  const asked = this.relayAsked.get(groupId) ?? /* @__PURE__ */ new Map();
@@ -26256,8 +26137,10 @@ var Groups = class {
26256
26137
  via,
26257
26138
  at: now
26258
26139
  });
26140
+ const ask = [...away, ...this.gapsOf(groupId, session, via, lacking)];
26141
+ if (!ask.length) continue;
26259
26142
  try {
26260
- this.host.sendOnLink(edges.get(via), session.syncFrame(away));
26143
+ this.host.sendOnLink(edges.get(via), session.syncFrame(ask));
26261
26144
  } catch {}
26262
26145
  }
26263
26146
  }
@@ -26402,7 +26285,7 @@ var Groups = class {
26402
26285
  }
26403
26286
  /** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
26404
26287
  async metaChanged(groupId, session, by, change, at) {
26405
- const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
26288
+ const name = by === void 0 ? void 0 : by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
26406
26289
  const when = at === void 0 ? Date.now() : receivedTimestamp(at);
26407
26290
  for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
26408
26291
  this.host.emit();
@@ -26997,7 +26880,8 @@ var GroupEdits = class {
26997
26880
  }
26998
26881
  /** An edge of a private group opened: the member at its other end hears my latest edits again, in case it missed them. */
26999
26882
  async resend(groupId, to) {
27000
- const mine = (await this.host.messages(chatOf(groupId))).filter((m) => m.sender === "me" && m.edit && !m.edit.pending && canEditInGroup(m)).sort((a, b) => b.edit.at - a.edit.at).slice(0, 16).reverse();
26883
+ const chat = chatOf(groupId);
26884
+ const mine = (await (this.host.history ? this.host.history(chat) : this.host.messages(chat))).filter((m) => m.sender === "me" && m.edit && !m.edit.pending && canEditInGroup(m)).sort((a, b) => b.edit.at - a.edit.at).slice(0, 16).reverse();
27001
26885
  for (const m of mine) await this.host.send(groupId, {
27002
26886
  id: m.id,
27003
26887
  e: m.edit.seq,
@@ -27137,7 +27021,8 @@ var CommunityPay = class {
27137
27021
  },
27138
27022
  memo: frame.memo,
27139
27023
  endpoints: frame.e,
27140
- ask: frame.a
27024
+ ask: frame.a,
27025
+ network: frame.n
27141
27026
  });
27142
27027
  else if (frame.t === "pay-ask") await this.host.onPaymentAsk(linkId, {
27143
27028
  id: frame.id,
@@ -27147,7 +27032,8 @@ var CommunityPay = class {
27147
27032
  asset: frame.u
27148
27033
  },
27149
27034
  method: frame.m,
27150
- memo: frame.memo
27035
+ memo: frame.memo,
27036
+ network: frame.n
27151
27037
  });
27152
27038
  else if (frame.t === "pay") await this.host.onPayment(linkId, {
27153
27039
  id: frame.id,
@@ -27191,7 +27077,8 @@ var CommunityPay = class {
27191
27077
  asset: frame.u
27192
27078
  },
27193
27079
  memo: frame.memo,
27194
- endpoints: frame.e
27080
+ endpoints: frame.e,
27081
+ network: frame.n
27195
27082
  });
27196
27083
  else if (frame.t === "pay-res" && frame.ok) await this.host.onPaymentResult(linkId, {
27197
27084
  id: frame.id,
@@ -27313,7 +27200,8 @@ var PairLink = class {
27313
27200
  u: r.amount.asset,
27314
27201
  memo: r.memo,
27315
27202
  e: r.endpoints,
27316
- a: r.ask
27203
+ a: r.ask,
27204
+ n: r.network
27317
27205
  });
27318
27206
  }
27319
27207
  async sendPaymentAsk(a) {
@@ -27325,7 +27213,8 @@ var PairLink = class {
27325
27213
  v: a.amount.value,
27326
27214
  u: a.amount.asset,
27327
27215
  m: a.method,
27328
- memo: a.memo
27216
+ memo: a.memo,
27217
+ n: a.network
27329
27218
  });
27330
27219
  }
27331
27220
  async sendPayment(p) {
@@ -27406,7 +27295,8 @@ var GroupLink = class {
27406
27295
  v: r.amount.value,
27407
27296
  u: r.amount.asset,
27408
27297
  memo: r.memo,
27409
- e: r.endpoints
27298
+ e: r.endpoints,
27299
+ n: r.network
27410
27300
  }));
27411
27301
  await this.host.sendApp(this.groupId, {
27412
27302
  ...frame,
@@ -28065,16 +27955,17 @@ var HoldEngine = class {
28065
27955
  }, held);
28066
27956
  } else if (header.kind === "file") {
28067
27957
  const meta = header.meta;
28068
- const voice = parseVoiceMeta(meta.voice, meta.mime);
28069
- const video = parseVideoMeta(meta.video, meta.mime);
28070
- const image = parseImageMeta(meta.image, meta.mime);
27958
+ const name = sanitizeFileName(meta.name), mime = sanitizeMime(meta.mime);
27959
+ const voice = parseVoiceMeta(meta.voice, mime);
27960
+ const video = parseVideoMeta(meta.video, mime);
27961
+ const image = parseImageMeta(meta.image, mime);
28071
27962
  const reply = readReply(meta.r, pairedReplyAuthor);
28072
27963
  const forwarded = readForwarded(meta.fw);
28073
27964
  const refused = await this.host.receiveFile(linkId, {
28074
27965
  wireId: header.id,
28075
- name: meta.name,
27966
+ name,
28076
27967
  size: meta.size,
28077
- mime: meta.mime,
27968
+ mime,
28078
27969
  timestamp: header.ts,
28079
27970
  ...voice && { voice },
28080
27971
  ...video && { video },
@@ -29376,6 +29267,9 @@ function assertAmount(amount) {
29376
29267
  var asProofLike = (proofs) => proofs;
29377
29268
  var total = (proofs) => proofs.reduce((sum, p) => sum + p.amount, 0);
29378
29269
  var sats = (proofs) => total(proofs.map((p) => ({ amount: p.amount.toNumber() })));
29270
+ var MINT_QUOTE_MISMATCH = "The mint quoted another amount than this invoice's. Nothing was paid.";
29271
+ /** A melt quote that is not for this invoice (`invoice` lower case, as `decodeBolt11` gives it) and its amount. */
29272
+ var quoteMismatch = (quote, invoice, amount) => quote.request.trim().toLowerCase().replace(/^lightning:/, "") !== invoice || quote.amount.toNumber() !== amount;
29379
29273
  var toStored = (mint, p, reserved) => ({
29380
29274
  mint,
29381
29275
  id: p.id,
@@ -29428,6 +29322,8 @@ var CashuWallet = class CashuWallet {
29428
29322
  failedFees = /* @__PURE__ */ new Map();
29429
29323
  /** Melt quotes that ended unpaid with proofs the mint read spent: ecash spent somewhere else, said as such. */
29430
29324
  spentAway = /* @__PURE__ */ new Set();
29325
+ /** Per melt quote shown to the person: the invoice, its amount and the fee reserve the mint quoted. Only these are paid. */
29326
+ shownQuotes = /* @__PURE__ */ new Map();
29431
29327
  /**
29432
29328
  * `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
29433
29329
  * invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
@@ -29469,7 +29365,10 @@ var CashuWallet = class CashuWallet {
29469
29365
  }
29470
29366
  /** How long `stop` waits for a swap in a per-mint lock. */
29471
29367
  static STOP_WAIT_MS = 3e4;
29472
- /** The Cashu wallet of one network: its mints and their balance. History is every network's, newest first. */
29368
+ /**
29369
+ * The Cashu wallet of one network: its mints, their balance, and the network's newest movements (every network's when
29370
+ * none is named) with the fees they paid. Only those movements are read, however long the history is.
29371
+ */
29473
29372
  async view(network) {
29474
29373
  const proofs = await this.allProofs();
29475
29374
  const mints = this.getMints(network).map((url) => ({
@@ -29478,8 +29377,7 @@ var CashuWallet = class CashuWallet {
29478
29377
  balance: total(proofs.filter((p) => p.mint === url && !p.reserved)),
29479
29378
  info: this.infos.get(url) ?? null
29480
29379
  }));
29481
- const all = await wrap((await store(STORES$1.walletTx, "readonly")).getAll());
29482
- all.sort((a, b) => b.timestamp - a.timestamp);
29380
+ const history = await newestWalletTx((mint) => !network || mintNetwork(mint) === network, HISTORY_SHOWN);
29483
29381
  const here = new Set(mints.map((m) => m.url));
29484
29382
  const swaps = (await wrap((await store(STORES$1.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
29485
29383
  const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
@@ -29491,8 +29389,8 @@ var CashuWallet = class CashuWallet {
29491
29389
  openSwaps: swaps.length,
29492
29390
  swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
29493
29391
  unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
29494
- history: all.slice(0, HISTORY_SHOWN),
29495
- feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
29392
+ history,
29393
+ feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0)
29496
29394
  };
29497
29395
  }
29498
29396
  /**
@@ -30056,7 +29954,7 @@ var CashuWallet = class CashuWallet {
30056
29954
  unit: metadata.unit,
30057
29955
  mint,
30058
29956
  memo: metadata.memo || void 0,
30059
- accepted: this.getKnownMints().includes(mint) || isTestMint(mint)
29957
+ accepted: metadata.unit === UNIT$1 && (this.getKnownMints().includes(mint) || isTestMint(mint))
30060
29958
  };
30061
29959
  }
30062
29960
  if (/^creq[AB]/i.test(value)) {
@@ -30079,14 +29977,16 @@ var CashuWallet = class CashuWallet {
30079
29977
  async receiveToken(token, kind = "ecash-in", note, { addTestMint = true, payment } = {}) {
30080
29978
  let mint;
30081
29979
  let faceValue;
29980
+ let unit;
30082
29981
  try {
30083
29982
  const metadata = getTokenMetadata(token);
30084
- if (metadata.unit !== UNIT$1) throw new Error("unit");
29983
+ unit = metadata.unit;
30085
29984
  mint = metadata.mint.replace(/\/+$/, "");
30086
29985
  faceValue = Number(metadata.amount);
30087
29986
  } catch {
30088
29987
  throw new Error("That is not a valid ecash token");
30089
29988
  }
29989
+ if (unit !== UNIT$1) throw engineError("ecashOtherUnit", { unit });
30090
29990
  if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
30091
29991
  if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
30092
29992
  return this.locked(mint, async () => {
@@ -30135,9 +30035,10 @@ var CashuWallet = class CashuWallet {
30135
30035
  }
30136
30036
  /**
30137
30037
  * Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
30138
- * no mint. Otherwise it is a swap: written down, with its inputs reserved, before the mint is asked. The caller
30139
- * stores the result in one transaction: `inputs` out, `keep` in, `send` where it goes, and `swap` deleted.
30140
- * Runs under the mint's lock.
30038
+ * no mint, as long as their token fits in a payment frame: a wallet of many small coins can add up exactly in
30039
+ * hundreds of them, a token the contact's app drops unread. Otherwise it is a swap, which sends a few coins: written
30040
+ * down, with its inputs reserved, before the mint is asked. The caller stores the result in one transaction: `inputs`
30041
+ * out, `keep` in, `send` where it goes, and `swap` deleted. Runs under the mint's lock.
30141
30042
  */
30142
30043
  async split(mint, amount, proofs) {
30143
30044
  const wallet = await this.wallet(mint);
@@ -30150,7 +30051,11 @@ var CashuWallet = class CashuWallet {
30150
30051
  includeFees: true,
30151
30052
  exactMatch: true
30152
30053
  });
30153
- if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber()) return {
30054
+ if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber() && getEncodedToken({
30055
+ mint,
30056
+ proofs: send,
30057
+ unit: UNIT$1
30058
+ }).length <= LIMITS.maxPaymentEndpointChars) return {
30154
30059
  inputs: among(send),
30155
30060
  keep: [],
30156
30061
  send
@@ -30467,7 +30372,10 @@ var CashuWallet = class CashuWallet {
30467
30372
  async quoteInvoice(invoice, network) {
30468
30373
  let lastError = /* @__PURE__ */ new Error("Add a mint in Settings first");
30469
30374
  let mints = this.getMints(network);
30470
- if (decodeBolt11(invoice.trim())?.network === "bitcoin" && mints.some((mint) => mintNetwork(mint) === "testnet")) {
30375
+ const decoded = decodeBolt11(invoice);
30376
+ if (!decoded) throw new Error("That is not an invoice this wallet can pay");
30377
+ if (decoded.amountSat === null) throw new Error("Invoices without an amount are not supported");
30378
+ if (decoded.network === "bitcoin" && mints.some((mint) => mintNetwork(mint) === "testnet")) {
30471
30379
  mints = mints.filter((mint) => mintNetwork(mint) === "mainnet" || fakesLightning(mint));
30472
30380
  if (!mints.length) throw new Error(BITCOIN_INVOICE_ON_TESTNET);
30473
30381
  }
@@ -30475,10 +30383,16 @@ var CashuWallet = class CashuWallet {
30475
30383
  const quote = await (await this.wallet(mint)).createMeltQuoteBolt11(invoice.trim());
30476
30384
  const amount = quote.amount.toNumber();
30477
30385
  const feeReserve = quote.fee_reserve.toNumber();
30386
+ if (quoteMismatch(quote, decoded.invoice, decoded.amountSat)) throw new Error(MINT_QUOTE_MISMATCH);
30478
30387
  if (await this.balanceAt(mint) < amount + feeReserve) {
30479
30388
  lastError = engineError("notEnoughSats");
30480
30389
  continue;
30481
30390
  }
30391
+ this.shownQuotes.set(quote.quote, {
30392
+ request: decoded.invoice,
30393
+ amount,
30394
+ feeReserve
30395
+ });
30482
30396
  return {
30483
30397
  quote: quote.quote,
30484
30398
  mint,
@@ -30510,6 +30424,8 @@ var CashuWallet = class CashuWallet {
30510
30424
  const wallet = await this.wallet(mint);
30511
30425
  const quote = await wallet.checkMeltQuoteBolt11(quoteId);
30512
30426
  if ((await wrap((await store(STORES$1.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
30427
+ const shown = this.shownQuotes.get(quoteId);
30428
+ if (!shown || quote.quote !== quoteId || quoteMismatch(quote, shown.request, shown.amount) || quote.fee_reserve.toNumber() > shown.feeReserve) throw new Error(MINT_QUOTE_MISMATCH);
30513
30429
  const needed = quote.amount.toNumber() + quote.fee_reserve.toNumber();
30514
30430
  const { inputs, keep, send, swap } = await this.split(mint, needed, await this.proofsAt(mint));
30515
30431
  let preview;
@@ -30544,6 +30460,7 @@ var CashuWallet = class CashuWallet {
30544
30460
  stores[STORES$1.melts].put(melt);
30545
30461
  if (swap) stores[STORES$1.swaps].delete(swap.id);
30546
30462
  });
30463
+ this.shownQuotes.delete(quoteId);
30547
30464
  let result;
30548
30465
  try {
30549
30466
  result = await wallet.completeMelt(preview);
@@ -30716,7 +30633,7 @@ var CashuWallet = class CashuWallet {
30716
30633
  let wallet = this.wallets.get(mint);
30717
30634
  if (!wallet) {
30718
30635
  wallet = (async () => {
30719
- const instance = new Wallet$1(mint, { unit: UNIT$1 });
30636
+ const instance = new Wallet(mint, { unit: UNIT$1 });
30720
30637
  await within(MINT_TIMEOUT_MS, instance.loadMint(), mint);
30721
30638
  return instance;
30722
30639
  })();
@@ -31343,7 +31260,7 @@ var PaymentDesk = class {
31343
31260
  if (request.target) throw new Error("Review and explicitly approve this payment before sending");
31344
31261
  if (request.state !== "pending") throw new Error("This request is no longer open");
31345
31262
  if (request.lightningPending) throw new Error("A Lightning payment for this request is still pending");
31346
- if ([...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "out" && p.requestId === request.id && p.state !== "reclaimed" && p.state !== "failed")) throw new Error("You already paid this request");
31263
+ if ([...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "out" && p.requestId === request.id && p.state !== "reclaimed" && (p.state !== "failed" || !!p.token))) throw new Error("You already paid this request");
31347
31264
  assertConfirmedReal(network, params.confirmedReal);
31348
31265
  const link = this.requireLink(params.linkId);
31349
31266
  await link.requirePaymentSupport();
@@ -31509,10 +31426,13 @@ var PaymentDesk = class {
31509
31426
  });
31510
31427
  return { askId };
31511
31428
  }
31512
- /** The ask a request answers, only if this side made it: same chat, way of paying and amount, still fresh. */
31513
- answering(linkId, request, method, amount) {
31429
+ /**
31430
+ * The ask a request answers, only if this side made it: same chat, way of paying and amount, still fresh, and the
31431
+ * network the request is of (what it says, else what it carries), never only what it says.
31432
+ */
31433
+ answering(linkId, request, method, amount, network) {
31514
31434
  const ask = request.ask ? this.asks.get(request.ask) : void 0;
31515
- if (!ask || ask.linkId !== linkId || ask.method !== method || ask.amount !== amount || ask.expiresAt < Date.now() || request.network && request.network !== ask.network) return void 0;
31435
+ if (!ask || ask.linkId !== linkId || ask.method !== method || ask.amount !== amount || ask.expiresAt < Date.now() || network !== ask.network) return void 0;
31516
31436
  this.asks.delete(request.ask);
31517
31437
  return request.ask;
31518
31438
  }
@@ -31635,7 +31555,7 @@ var PaymentDesk = class {
31635
31555
  await this.save({
31636
31556
  target,
31637
31557
  federations,
31638
- ask: federations ? this.answering(linkId, request, "fedimint", amount) : target?.method === "arkade" || target?.method === "bark" || target?.method === "bitcoin" || target?.method === "spark" ? this.answering(linkId, request, target.method, amount) : void 0,
31558
+ ask: federations ? this.answering(linkId, request, "fedimint", amount, network) : target?.method === "arkade" || target?.method === "bark" || target?.method === "bitcoin" || target?.method === "spark" ? this.answering(linkId, request, target.method, amount, network) : void 0,
31639
31559
  id: this.keyFor(linkId, request.id),
31640
31560
  linkId,
31641
31561
  kind: "request",
@@ -32061,7 +31981,7 @@ var PaymentDesk = class {
32061
31981
  memo: request.memo,
32062
31982
  state: "pending",
32063
31983
  createdAt: request.timestamp,
32064
- ask: this.answering(linkId, request, "usdt", amount),
31984
+ ask: this.answering(linkId, request, "usdt", amount, network),
32065
31985
  network
32066
31986
  });
32067
31987
  await this.host.storeMessage({
@@ -32302,6 +32222,7 @@ var PaymentDesk = class {
32302
32222
  }
32303
32223
  if ([...this.payments.values()].some((p) => p.txid === txid && (p.linkId !== linkId || (p.kind === "request" ? p.id !== request.id : p.requestId !== request.id)))) return;
32304
32224
  if (typeof txid !== "string" || !/^[a-f0-9]{64}$/.test(txid) || existing?.state === "settled") return;
32225
+ if (!existing && [...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "in" && p.requestId === request.id && p.state === "pending")) return;
32305
32226
  await this.save({
32306
32227
  id: payment.id,
32307
32228
  linkId,
@@ -32592,8 +32513,7 @@ var PaymentDesk = class {
32592
32513
  for (const request of [...this.payments.values()]) {
32593
32514
  if (request.kind !== "request" || request.direction !== "out" || request.state !== "pending" || request.target?.method !== "bitcoin") continue;
32594
32515
  const claimed = new Set([...this.payments.values()].filter((p) => p.target?.method === "bitcoin" && p.kind === "request" && p.id !== request.id && p.txid).map((p) => p.txid));
32595
- const receipt = [...this.payments.values()].find((p) => p.kind === "payment" && p.direction === "in" && p.requestId === request.id && p.linkId === request.linkId);
32596
- const seen = await this.bitcoin[walletNetworkOf(request.target.network)].received(request.target, request.amount, claimed, receipt?.txid).catch(() => void 0);
32516
+ const seen = await this.bitcoin[walletNetworkOf(request.target.network)].received(request.target, request.amount, claimed).catch(() => void 0);
32597
32517
  if (!seen || seen.confirmations < 1) continue;
32598
32518
  await this.settleRequest(request, { txid: seen.txid });
32599
32519
  for (const payment of this.payments.values()) if (payment.kind === "payment" && payment.direction === "in" && payment.requestId === request.id && payment.state === "pending") await this.save({
@@ -33444,7 +33364,7 @@ function pushRelayProblem(url) {
33444
33364
  "localhost",
33445
33365
  "[::1]"
33446
33366
  ].includes(parsed.hostname)) return null;
33447
- return "Use an https:// relay address";
33367
+ return `Use an https:// relay address: ${url}`;
33448
33368
  }
33449
33369
  //#endregion
33450
33370
  //#region ../browser/src/engine/walletSetup.ts
@@ -33565,6 +33485,8 @@ var RELAY_RETRY_MS = 3e4;
33565
33485
  var NATIVE_SLOTS = 8;
33566
33486
  /** How often a group link that found no free native slot tries again. */
33567
33487
  var GROUP_NATIVE_RETRY_MS = 15e3;
33488
+ /** The longest a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
33489
+ var GROUP_NATIVE_FAILED_MAX_MS = 3e5;
33568
33490
  /** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
33569
33491
  var CLEAR_WITHDRAW_MS = 6e3;
33570
33492
  /**
@@ -33595,6 +33517,16 @@ var CARD_RESTORE_MOST = 50;
33595
33517
  var CARD_RESTORE_MS = 6048e5;
33596
33518
  /** A reaction said on the live session and not confirmed is said again after this long. */
33597
33519
  var REACTION_RESEND_MS = 3e4;
33520
+ /** This side's participation key in a chat: derived from its seed once, not at every state built. */
33521
+ function participationKeyOf(live) {
33522
+ const seed = live.stored.participationSeed;
33523
+ if (!seed) return void 0;
33524
+ if (live.participationKey?.seed !== seed) live.participationKey = {
33525
+ seed,
33526
+ key: identityFromSeedB64(seed).pubKeyZ32
33527
+ };
33528
+ return live.participationKey.key;
33529
+ }
33598
33530
  function emptyLinkFiles() {
33599
33531
  return {
33600
33532
  receivedBytes: 0,
@@ -33800,6 +33732,11 @@ var GhostlyNode = class GhostlyNode {
33800
33732
  return Promise.all(this.nativeQueues.values()).then(() => {});
33801
33733
  }
33802
33734
  shuttingDown = false;
33735
+ /** Settles as `shutdown` begins: what is still starting is not waited for past it. */
33736
+ stopBegan;
33737
+ stopping = new Promise((resolve) => {
33738
+ this.stopBegan = () => resolve(null);
33739
+ });
33803
33740
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
33804
33741
  transfersRestored = false;
33805
33742
  feedbackStartedAt = Date.now();
@@ -34388,10 +34325,10 @@ var GhostlyNode = class GhostlyNode {
34388
34325
  } : void 0;
34389
34326
  },
34390
34327
  keys: (linkId) => {
34391
- const stored = this.links.get(linkId)?.stored;
34328
+ const live = this.links.get(linkId);
34392
34329
  return {
34393
- mine: stored?.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0,
34394
- theirs: stored?.pairedPeerKey
34330
+ mine: live && participationKeyOf(live),
34331
+ theirs: live?.stored.pairedPeerKey
34395
34332
  };
34396
34333
  },
34397
34334
  linkIds: () => [...this.links.keys()],
@@ -34430,9 +34367,9 @@ var GhostlyNode = class GhostlyNode {
34430
34367
  emit: () => this.emitState(),
34431
34368
  ownSubjects: () => this.identities.views().filter((p) => p.provider === "nostr").map((p) => p.subject),
34432
34369
  contactSubjects: (linkId) => {
34433
- const stored = this.links.get(linkId)?.stored;
34370
+ const live = this.links.get(linkId), stored = live?.stored;
34434
34371
  if (!stored?.profile || !stored.identities) return [];
34435
- const mine = stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0;
34372
+ const mine = participationKeyOf(live);
34436
34373
  const t = Math.floor(Date.now() / 1e3);
34437
34374
  return [...new Set(stored.identities.received.filter((r) => r.binding.provider === "nostr" && receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified").map((r) => r.verified.subject))];
34438
34375
  },
@@ -34494,7 +34431,7 @@ var GhostlyNode = class GhostlyNode {
34494
34431
  for (const live of this.links.values()) {
34495
34432
  const stored = live.stored;
34496
34433
  if (!stored.profile || !stored.identities || stored.group) continue;
34497
- const mine = stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0;
34434
+ const mine = participationKeyOf(live);
34498
34435
  for (const r of stored.identities.received) if (receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified") out.push({
34499
34436
  provider: r.binding.provider,
34500
34437
  subject: r.verified.subject,
@@ -34544,6 +34481,11 @@ var GhostlyNode = class GhostlyNode {
34544
34481
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34545
34482
  changed: async (chat, id, note) => {
34546
34483
  if (note) this.reactionNotes.set(chat, note);
34484
+ else if (this.reactionNotes.get(chat)?.message === id) {
34485
+ const before = latestReaction(await db.getMessages(chat));
34486
+ if (before) this.reactionNotes.set(chat, before);
34487
+ else this.reactionNotes.delete(chat);
34488
+ }
34547
34489
  await this.messagesChanged(chat, [id]);
34548
34490
  this.emitState();
34549
34491
  },
@@ -34669,6 +34611,7 @@ var GhostlyNode = class GhostlyNode {
34669
34611
  */
34670
34612
  groupEdits = new GroupEdits({
34671
34613
  messages: (chat) => db.getMessages(chat),
34614
+ history: (chat) => this.edgeOpenHistory(chat),
34672
34615
  message: (chat, id) => db.getMessage(chat, id),
34673
34616
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34674
34617
  changed: (chat, id) => this.messagesChanged(chat, [id]),
@@ -34878,7 +34821,7 @@ var GhostlyNode = class GhostlyNode {
34878
34821
  const awaiting = await this.readAwaiting();
34879
34822
  for (const network of WALLET_NETWORKS) {
34880
34823
  const view = await this.wallet.view(network);
34881
- everything = view.history;
34824
+ everything = everything.concat(view.history);
34882
34825
  const history = view.history.filter((tx) => !tx.mint || mintNetwork(tx.mint) === network);
34883
34826
  networks[network] = {
34884
34827
  mints: this.withWaits(view.mints, awaiting.mints),
@@ -35038,7 +34981,7 @@ var GhostlyNode = class GhostlyNode {
35038
34981
  }
35039
34982
  this.transfersRestored = true;
35040
34983
  for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
35041
- await this.groups.load();
34984
+ await this.groups.load(this.limitedMode);
35042
34985
  for (const group of this.groups.views()) {
35043
34986
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
35044
34987
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -35438,12 +35381,17 @@ var GhostlyNode = class GhostlyNode {
35438
35381
  async shutdown(options = {}) {
35439
35382
  const quiet = options.quiet === true || this.gatedOut;
35440
35383
  this.shuttingDown = true;
35384
+ this.stopBegan();
35441
35385
  if (this.limitedTimer) {
35442
35386
  clearTimeout(this.limitedTimer);
35443
35387
  this.limitedTimer = null;
35444
35388
  }
35445
35389
  this.appShelf?.stop();
35446
35390
  if (!quiet) this.depart();
35391
+ const linksStopped = Promise.allSettled([...this.links.values()].map(async (live) => {
35392
+ live.carried?.stop();
35393
+ await Promise.all([live.link?.stop(!quiet), live.caps?.stop()]);
35394
+ }));
35447
35395
  this.directPath.close();
35448
35396
  this.clock.close();
35449
35397
  this.clockOff?.();
@@ -35471,33 +35419,30 @@ var GhostlyNode = class GhostlyNode {
35471
35419
  this.activeTurnTimer = null;
35472
35420
  if (this.restoreTimer) clearTimeout(this.restoreTimer);
35473
35421
  this.restoreTimer = null;
35474
- await links?.stop().catch(() => {});
35475
35422
  this.identities.stop();
35476
35423
  this.did.stop();
35477
35424
  this.nostrSocial.stop();
35478
35425
  this.publicProfiles.stop();
35479
35426
  this.publicActivity.clear();
35480
- for (const network of WALLET_NETWORKS) {
35481
- await this.arkWallets[network].stop();
35482
- await this.barkWallets[network].stop();
35483
- await this.fedimintWallets[network].stop();
35484
- await this.sparkWallets[network].stop();
35485
- await this.usdtWallets[network].stop();
35486
- await this.lightnings[network].stop();
35487
- await this.bitcoins[network].stop();
35488
- }
35489
- await this.wallet.stop();
35490
- await this.nativeQueue;
35491
- await this.hold.stop();
35492
- await Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop()));
35493
35427
  for (const queue of this.editQueues.values()) queue.stop();
35494
35428
  this.groupEdits.stop();
35495
35429
  this.cardEdits.stop();
35496
- await Promise.allSettled([...this.links.values()].map(async (live) => {
35497
- live.carried?.stop();
35498
- await live.link?.stop(!quiet);
35499
- await live.caps?.stop();
35500
- }));
35430
+ await Promise.allSettled([
35431
+ linksStopped,
35432
+ links?.stop(),
35433
+ ...WALLET_NETWORKS.flatMap((network) => [
35434
+ this.arkWallets,
35435
+ this.barkWallets,
35436
+ this.fedimintWallets,
35437
+ this.sparkWallets,
35438
+ this.usdtWallets,
35439
+ this.lightnings,
35440
+ this.bitcoins
35441
+ ].map((wallets) => wallets[network].stop())),
35442
+ this.wallet.stop(),
35443
+ this.nativeQueue,
35444
+ Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop())).then(() => this.hold.stop())
35445
+ ]);
35501
35446
  }
35502
35447
  /**
35503
35448
  * "Clear all data" (WISP 04), the peer's part. First what can be taken back from the network, best effort and within
@@ -35518,7 +35463,7 @@ var GhostlyNode = class GhostlyNode {
35518
35463
  appShelf;
35519
35464
  /** The installed apps and stores, refused while the apps feature is off. */
35520
35465
  get appStore() {
35521
- if (!this.appsOn) throw new Error("Apps are unavailable in this release");
35466
+ if (!this.appsOn) throw this.appsOff();
35522
35467
  return this.appShelf ??= new Apps({
35523
35468
  fetch: boundedAppFetch({
35524
35469
  fetcher: this.options.appFetch,
@@ -35674,6 +35619,10 @@ var GhostlyNode = class GhostlyNode {
35674
35619
  getMessages(linkId) {
35675
35620
  return db.getMessages(linkId);
35676
35621
  }
35622
+ /** One message of a chat, read by its id: the whole history costs a read of every row. */
35623
+ getMessage(linkId, id) {
35624
+ return db.getMessage(linkId, id);
35625
+ }
35677
35626
  async messagePage({ linkId, limit = 50, before }) {
35678
35627
  if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("limit: a whole number of messages, at least 1");
35679
35628
  if (before === void 0) return db.getMessagePage(linkId, { limit });
@@ -36438,6 +36387,10 @@ var GhostlyNode = class GhostlyNode {
36438
36387
  this.transport.networkChanged?.();
36439
36388
  this.directPath.reset();
36440
36389
  }
36390
+ if (params.network) for (const failed of this.groupNativeFailed.values()) {
36391
+ failed.at = 0;
36392
+ failed.wait = 0;
36393
+ }
36441
36394
  for (const live of this.links.values()) live.link?.wake({ network: params.network });
36442
36395
  this.hold.wake();
36443
36396
  for (const network of WALLET_NETWORKS) {
@@ -36661,12 +36614,26 @@ var GhostlyNode = class GhostlyNode {
36661
36614
  /**
36662
36615
  * A received reply, or one sent before its original was here, against this chat's own history: the original found
36663
36616
  * here gives the line and the author (and it is then checked); one not found keeps what the wire said, unchecked.
36617
+ * The original is read alone where its id says its row (a group's message id, a chat's `peer_`/`me_` text): a reply
36618
+ * costs what a plain message does, however long the chat. A group's row goes by its own id only, so a miss there is
36619
+ * not here; a chat's other rows (a file's, a payment's) are looked for in the whole chat.
36664
36620
  */
36665
36621
  async resolveReply(message) {
36666
36622
  const reply = message.replyTo;
36667
36623
  if (!reply || reply.messageId) return message;
36668
- const history = await db.getMessages(message.linkId);
36669
- const original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
36624
+ const group = message.linkId.startsWith("group:");
36625
+ let original, history;
36626
+ for (const rowId of group ? [reply.id] : [`peer_${reply.id}`, `me_${reply.id}`]) {
36627
+ const row = rowId === message.id ? void 0 : await db.getMessage(message.linkId, rowId);
36628
+ if (row && replyRef(row, group) === reply.id) {
36629
+ original = row;
36630
+ break;
36631
+ }
36632
+ }
36633
+ if (!original && !group) {
36634
+ history = await db.getMessages(message.linkId);
36635
+ original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
36636
+ }
36670
36637
  if (!original) return message;
36671
36638
  const resolved = {
36672
36639
  ...message,
@@ -36676,7 +36643,7 @@ var GhostlyNode = class GhostlyNode {
36676
36643
  ...reply.button && { button: reply.button }
36677
36644
  }
36678
36645
  };
36679
- const press = buttonPress(resolved, original, history);
36646
+ const press = buttonPress(resolved, original, []) && buttonPress(resolved, original, history ?? await db.getMessages(message.linkId));
36680
36647
  return press ? {
36681
36648
  ...resolved,
36682
36649
  press
@@ -36842,19 +36809,27 @@ var GhostlyNode = class GhostlyNode {
36842
36809
  * message goes again as an edit of its card alone: the same text, so no version and no edit mark. Once per message (the
36843
36810
  * row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later edit of the
36844
36811
  * author's replaces it, the highest number winning). A contact whose app does not show the kind (it would drop the card
36845
- * and mark the text edited) gets none: the row waits for one that does. Bounded: the newest `CARD_RESTORE_MOST` such
36846
- * messages of the last `CARD_RESTORE_MS`; older ones are given up. The edit is marked `restore`: the bot's own event
36847
- * stream says nothing of it, while its number counts, so a later edit of the bot's takes the next one.
36812
+ * and mark the text edited) gets none: the row waits for one that does. Bounded: only the newest message of each card
36813
+ * (kind and id: a bot may send a task again as a new message), the newest `CARD_RESTORE_MOST` such cards of the last
36814
+ * `CARD_RESTORE_MS`; the others are given up. The edit queue spaces them (`CARD_RESTORE_GAP_MS`), so a reconnect does
36815
+ * not burst. The edit is marked `restore`: the bot's own event stream says nothing of it, while its number counts, so a
36816
+ * later edit of the bot's takes the next one.
36848
36817
  */
36849
36818
  async restoreCards(linkId) {
36850
36819
  const live = this.links.get(linkId), link = live?.link;
36851
36820
  const offers = link?.sessionOffers.peer;
36852
36821
  if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !offers) return;
36853
- const due = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && (m.cardRestore ?? m.buttonsRestore) === "due").sort((a, b) => b.timestamp - a.timestamp);
36822
+ const mine = (await db.getMessages(linkId)).filter((m) => m.sender === "me").sort((a, b) => b.timestamp - a.timestamp);
36854
36823
  const since = Date.now() - CARD_RESTORE_MS;
36855
36824
  const changed = [];
36856
- for (const [index, message] of due.entries()) {
36857
- const stale = index >= CARD_RESTORE_MOST || message.timestamp < since;
36825
+ const cards = /* @__PURE__ */ new Set();
36826
+ let kept = 0;
36827
+ for (const message of mine) {
36828
+ const key = message.card && `${message.card.kind}\n${message.card.id}`;
36829
+ const superseded = !!key && cards.has(key);
36830
+ if (key) cards.add(key);
36831
+ if ((message.cardRestore ?? message.buttonsRestore) !== "due") continue;
36832
+ const stale = superseded || kept++ >= CARD_RESTORE_MOST || message.timestamp < since;
36858
36833
  if (!stale && message.card && !offers.includes(GhostlyNode.cardCapability(message.card))) continue;
36859
36834
  const at = Date.now();
36860
36835
  if ((await db.patchMessage(linkId, message.id, (current) => {
@@ -37045,7 +37020,7 @@ var GhostlyNode = class GhostlyNode {
37045
37020
  return composeDetails(message, {
37046
37021
  link: live && stored && {
37047
37022
  stored,
37048
- myKey: stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : live.myPubKeyZ32,
37023
+ myKey: participationKeyOf(live) ?? live.myPubKeyZ32,
37049
37024
  verified: !!stored.pairedPeerKey && (stored.peerTrust ? stored.peerTrust.verifiedKey === stored.pairedPeerKey : true),
37050
37025
  transportNow: live.link?.isDataLinkOpen ? live.pairing?.transport : void 0,
37051
37026
  relayedNow: !!live.link?.relayedPath,
@@ -37856,11 +37831,23 @@ var GhostlyNode = class GhostlyNode {
37856
37831
  /**
37857
37832
  * `chosen`: a choice from the chat's Connection menu, a row in its timeline even when it names the transport already
37858
37833
  * set. Left out (the RPC), only a change of transport is: the Fallback switch sends the same preference again, and
37859
- * is neither a row nor a switch intent.
37834
+ * is neither a row nor a switch intent. On Automatic it sends back the transport Automatic names: only the fallback
37835
+ * changes, and the chat stays on Automatic.
37860
37836
  */
37861
37837
  async setTransportPreference({ linkId, preferred, fallback }, chosen) {
37862
37838
  const live = this.links.get(linkId);
37863
37839
  if (!live?.stored.profile || !live.link?.availableTransports.includes(preferred) || typeof fallback !== "boolean") throw new Error("Transport unavailable");
37840
+ if (chosen === void 0 && live.stored.preferredTransport === void 0 && preferred === automaticTransport(live.link.availableTransports)) {
37841
+ await db.patchLink(linkId, { transportFallback: fallback });
37842
+ live.stored = {
37843
+ ...live.stored,
37844
+ transportFallback: fallback
37845
+ };
37846
+ this.emitState();
37847
+ await live.link.setTransportPreference(preferred, fallback, true, false);
37848
+ this.capsChanged(linkId);
37849
+ return;
37850
+ }
37864
37851
  chosen ??= live.stored.preferredTransport !== preferred;
37865
37852
  const patch = {
37866
37853
  preferredTransport: preferred,
@@ -38107,6 +38094,18 @@ var GhostlyNode = class GhostlyNode {
38107
38094
  f: reply.member
38108
38095
  }, readForwarded(hops));
38109
38096
  }
38097
+ /**
38098
+ * Whether `sendGroupFile` would take a file of mine for the group now, asked before its bytes are copied in: the
38099
+ * error it would answer for the group's pace of files or for not being in it, or null. Not a promise: it may still
38100
+ * refuse (the minute's files can fill in between).
38101
+ */
38102
+ groupFileCheck({ groupId }) {
38103
+ if (typeof groupId !== "string") return {
38104
+ error: "No such group",
38105
+ refused: true
38106
+ };
38107
+ return this.groups.fileCheck(groupId);
38108
+ }
38110
38109
  groupMessages({ groupId }) {
38111
38110
  return this.groups.messages(groupId);
38112
38111
  }
@@ -38394,19 +38393,47 @@ var GhostlyNode = class GhostlyNode {
38394
38393
  } catch {}
38395
38394
  return { error: null };
38396
38395
  }
38397
- /** An edge of a private group opened: the member hears my latest reactions again, in case it missed them. */
38396
+ /** A group's history read in flight for edges that opened, by chat: its callers of the moment share it. */
38397
+ edgeOpenReads = /* @__PURE__ */ new Map();
38398
+ /**
38399
+ * A group's whole history, for what an edge that opens hears again (my latest edits and reactions): the edges that
38400
+ * open together, a member back in a group of 8 opening 7, read it once, not twice each. Ended, the next one reads anew.
38401
+ */
38402
+ edgeOpenHistory(chat) {
38403
+ let read = this.edgeOpenReads.get(chat);
38404
+ if (!read) {
38405
+ read = db.getMessages(chat).finally(() => this.edgeOpenReads.delete(chat));
38406
+ this.edgeOpenReads.set(chat, read);
38407
+ }
38408
+ return read;
38409
+ }
38410
+ /**
38411
+ * An edge of a private group opened: the member hears my latest reactions again, in case it missed them. The newest
38412
+ * `REACTION_LIMITS.send` go now and the rest a window later, while the edge is the same: the member takes 30 a window
38413
+ * from me, and 32 at once lost two of them.
38414
+ */
38398
38415
  async resendGroupReactions(groupId, linkId) {
38399
38416
  if (this.outsideEdge(linkId)) return;
38400
- const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
38401
- for (const reaction of mine) try {
38402
- this.links.get(linkId)?.link?.sendGroupFrame({
38403
- t: GROUP_REACTION_FRAME,
38404
- g: groupId,
38405
- ...reaction,
38406
- ...this.groups.signReaction(groupId, reaction)
38407
- });
38408
- } catch {
38409
- return;
38417
+ const mine = groupReactionsToResend(await this.edgeOpenHistory(`group:${groupId}`), REACTION_LIMITS.pending);
38418
+ const link = this.links.get(linkId)?.link;
38419
+ const say = (batch) => {
38420
+ for (const reaction of batch) try {
38421
+ link?.sendGroupFrame({
38422
+ t: GROUP_REACTION_FRAME,
38423
+ g: groupId,
38424
+ ...reaction,
38425
+ ...this.groups.signReaction(groupId, reaction)
38426
+ });
38427
+ } catch {
38428
+ return;
38429
+ }
38430
+ };
38431
+ say(mine.slice(0, REACTION_LIMITS.send));
38432
+ for (let start = REACTION_LIMITS.send, turn = 1; start < mine.length; start += REACTION_LIMITS.send, turn++) {
38433
+ const batch = mine.slice(start, start + REACTION_LIMITS.send);
38434
+ setTimeout(() => {
38435
+ if (link && this.links.get(linkId)?.link === link) say(batch);
38436
+ }, turn * (REACTION_LIMITS.windowMs + 1e3));
38410
38437
  }
38411
38438
  }
38412
38439
  /**
@@ -38473,11 +38500,15 @@ var GhostlyNode = class GhostlyNode {
38473
38500
  }
38474
38501
  /** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
38475
38502
  get appsOn() {
38476
- return this.options.apps ?? false;
38503
+ return this.options.apps ?? true;
38504
+ }
38505
+ /** Why an app call is refused: this client runs no mini-app (`apps: false`), or the build has them off. */
38506
+ appsOff() {
38507
+ return /* @__PURE__ */ new Error(this.options.apps === false ? "Apps do not run on this client" : "Apps are unavailable in this release");
38477
38508
  }
38478
38509
  /** A paired 1:1 chat and the chat app id of `ref` in it, from the two pinned participation keys (WISP 1200 § In a chat). */
38479
38510
  appChat(linkId, ref) {
38480
- if (!this.appsOn) throw new Error("Apps are unavailable in this release");
38511
+ if (!this.appsOn) throw this.appsOff();
38481
38512
  const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
38482
38513
  if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
38483
38514
  if (!isAppRef(ref)) throw new Error("Not an app reference");
@@ -39420,7 +39451,7 @@ var GhostlyNode = class GhostlyNode {
39420
39451
  if (params.requestId) {
39421
39452
  if (!request || request.linkId !== params.linkId || request.direction !== "in" || request.state !== "pending" || request.amount !== params.amount) throw new Error("Payment review does not match the authenticated request");
39422
39453
  if (params.target.method !== "cashu" ? JSON.stringify(request.target) !== JSON.stringify(params.target) : !!request.target || params.target.address !== request.id || !request.mints?.includes(params.target.provider)) throw new Error("Selected method or mint does not match the authenticated request");
39423
- if (request.lightningPending || Object.values(this.desk.views()).some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && !["failed", "reclaimed"].includes(p.state))) throw engineError("requestPaymentInFlight");
39454
+ if (request.lightningPending || this.desk.records().some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && p.state !== "reclaimed" && (p.state !== "failed" || !!p.token))) throw engineError("requestPaymentInFlight");
39424
39455
  const asked = paymentNetwork(request);
39425
39456
  if (asked !== paying) throw new Error(crossNetwork(paying, asked));
39426
39457
  } else if (params.target.method !== "cashu" || !payee || params.target.address !== payee) throw new Error("Destination does not match the authenticated peer");
@@ -40562,6 +40593,7 @@ var GhostlyNode = class GhostlyNode {
40562
40593
  this.groupWakeReceived.delete(linkId);
40563
40594
  this.quietEdges.delete(linkId);
40564
40595
  this.groupNativeWaiting.delete(linkId);
40596
+ this.groupNativeFailed.delete(linkId);
40565
40597
  this.groupNativeRetryAt = 0;
40566
40598
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
40567
40599
  const group = live.stored.group, peer = live.stored.groupPeer;
@@ -40730,6 +40762,7 @@ var GhostlyNode = class GhostlyNode {
40730
40762
  automatic: true
40731
40763
  },
40732
40764
  packetTransports: !this.options.webrtcGroupLinks,
40765
+ packetTransportsSaid: !!stored.transportSeeds && Object.keys(stored.transportSeeds).length > 0,
40733
40766
  resumeFloor: !entry && stored.edgeLive ? stored.edgeLiveSince : void 0,
40734
40767
  pairing: {
40735
40768
  credentials: {
@@ -40790,6 +40823,21 @@ var GhostlyNode = class GhostlyNode {
40790
40823
  };
40791
40824
  db.patchLink(linkId, patch).catch(() => {});
40792
40825
  if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
40826
+ else this.groupNativeWaiting.delete(linkId);
40827
+ },
40828
+ onPacketTransportsGone: () => {
40829
+ const patch = {
40830
+ peerTransports: void 0,
40831
+ peerDescriptors: void 0,
40832
+ peerFallback: void 0
40833
+ };
40834
+ live.stored = {
40835
+ ...live.stored,
40836
+ ...patch
40837
+ };
40838
+ db.patchLink(linkId, patch).catch(() => {});
40839
+ if (!this.keepsGroupNative(live.stored)) this.groupNativeWaiting.delete(linkId);
40840
+ this.emitState();
40793
40841
  },
40794
40842
  ...entry ? {} : {
40795
40843
  onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
@@ -40832,12 +40880,16 @@ var GhostlyNode = class GhostlyNode {
40832
40880
  this.emitState();
40833
40881
  },
40834
40882
  onStatus: (status) => {
40835
- live.status = status;
40836
- this.emitState();
40883
+ if (status !== live.status) {
40884
+ live.status = status;
40885
+ this.emitState();
40886
+ }
40837
40887
  },
40838
40888
  onDiscoveryError: (error) => {
40839
- live.discoveryError = error ?? void 0;
40840
- this.emitState();
40889
+ if ((error ?? void 0) !== live.discoveryError) {
40890
+ live.discoveryError = error ?? void 0;
40891
+ this.emitState();
40892
+ }
40841
40893
  }
40842
40894
  }
40843
40895
  });
@@ -40889,6 +40941,7 @@ var GhostlyNode = class GhostlyNode {
40889
40941
  expectedPeerKey: stored.peerParticipationKeyZ32
40890
40942
  } : void 0;
40891
40943
  const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? resumeHere(this.transportLogOf(live), this.transportsHere(), stored.peerTransports ?? live.caps?.peer?.transports) : {};
40944
+ let dhtShown;
40892
40945
  live.link = new GhostLink({
40893
40946
  paymentMethods: stored.paymentMethods,
40894
40947
  paymentNetworks: this.chatNetworks(stored),
@@ -41012,10 +41065,14 @@ var GhostlyNode = class GhostlyNode {
41012
41065
  },
41013
41066
  onGroupFrame: stored.profile ? (frame) => this.groups.handleContactFrame(linkId, frame) : void 0,
41014
41067
  onGroupsSupport: () => this.emitState(),
41015
- onDhtDelivery: () => {
41068
+ onDhtDelivery: (view) => {
41016
41069
  if (stored.profile && !stored.group) this.outboxes.get(linkId)?.flush().catch(() => {}).then(() => this.editQueues.get(linkId)?.flush()).catch(() => {});
41017
41070
  this.observeTransport(linkId);
41018
- this.emitState();
41071
+ const shown = JSON.stringify(view);
41072
+ if (shown !== dhtShown) {
41073
+ dhtShown = shown;
41074
+ this.emitState();
41075
+ }
41019
41076
  },
41020
41077
  onHold: (state) => this.hold.peerSaid(linkId, state),
41021
41078
  onPeerProof: void 0,
@@ -41053,8 +41110,10 @@ var GhostlyNode = class GhostlyNode {
41053
41110
  else this.emitState();
41054
41111
  },
41055
41112
  onDiscoveryError: (error) => {
41056
- live.discoveryError = error ?? void 0;
41057
- this.emitState();
41113
+ if ((error ?? void 0) !== live.discoveryError) {
41114
+ live.discoveryError = error ?? void 0;
41115
+ this.emitState();
41116
+ }
41058
41117
  },
41059
41118
  onTransportDiscovery: async (peerDescriptors, peerTransports, peerFallback) => {
41060
41119
  const patch = {
@@ -41079,23 +41138,27 @@ var GhostlyNode = class GhostlyNode {
41079
41138
  this.emitState();
41080
41139
  },
41081
41140
  onStatus: (status) => {
41141
+ const was = live.status, synced = live.lastSyncAt;
41082
41142
  live.status = status;
41083
41143
  if (status === "online") live.lastSyncAt = Date.now();
41084
- this.emitState();
41144
+ if (status !== was || !synced || stored.group || linkId === this.activeLinkId) this.emitState();
41085
41145
  },
41086
41146
  onPoll: ({ polling, nextInMs }) => {
41147
+ const was = live.poll.polling;
41087
41148
  live.poll = {
41088
41149
  polling,
41089
41150
  nextAt: Date.now() + nextInMs,
41090
41151
  interval: nextInMs || live.poll.interval
41091
41152
  };
41092
- this.emitState();
41153
+ if (linkId === this.activeLinkId || !live.lastSyncAt && polling !== was) this.emitState();
41093
41154
  },
41094
41155
  onMessageReceipt: async (id) => {
41095
- if (stored.profile && !stored.group) await this.fileSeenOnFloor(linkId, id);
41156
+ const oneToOne = !!stored.profile && !stored.group;
41157
+ const mine = oneToOne ? await db.getMessage(linkId, `me_${id}`) : void 0;
41158
+ if (oneToOne && !mine) await this.fileSeenOnFloor(linkId, id);
41096
41159
  await this.outboxFor(linkId).received(id);
41097
- if (stored.profile && !stored.group) await this.editsFor(linkId).receivedOnDht(id).catch(() => {});
41098
- if (stored.profile && !stored.group) this.editsFor(linkId).flush().catch(() => {});
41160
+ if (oneToOne && !mine) await this.editsFor(linkId).receivedOnDht(id).catch(() => {});
41161
+ if (oneToOne && (!mine || mine.edit?.pending)) this.editsFor(linkId).flush().catch(() => {});
41099
41162
  },
41100
41163
  onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
41101
41164
  onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
@@ -41288,7 +41351,7 @@ var GhostlyNode = class GhostlyNode {
41288
41351
  }
41289
41352
  }
41290
41353
  nativeUp?.then(() => {
41291
- if (live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
41354
+ if (!this.shuttingDown && live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
41292
41355
  });
41293
41356
  }
41294
41357
  /**
@@ -41368,7 +41431,7 @@ var GhostlyNode = class GhostlyNode {
41368
41431
  get nativeFactories() {
41369
41432
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
41370
41433
  return {
41371
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-CnEkFl8R.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41434
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-DmTrpmba.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41372
41435
  ...this.options.nativeTransports,
41373
41436
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
41374
41437
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -41470,13 +41533,23 @@ var GhostlyNode = class GhostlyNode {
41470
41533
  const live = this.links.get(linkId), link = live?.link;
41471
41534
  if (this.shuttingDown || !live || !link || link !== expected) return;
41472
41535
  if (live.stored.group && this.keepsGroupNative(live.stored)) {
41473
- if (full.some(Boolean) && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
41536
+ if (full.includes("no-slot") && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
41474
41537
  else this.groupNativeWaiting.delete(linkId);
41475
- }
41538
+ if (full.includes("failed")) {
41539
+ const wait = Math.min(Math.max(GROUP_NATIVE_RETRY_MS, (this.groupNativeFailed.get(linkId)?.wait ?? 0) * 2), GROUP_NATIVE_FAILED_MAX_MS);
41540
+ this.groupNativeFailed.set(linkId, {
41541
+ at: Date.now() + wait,
41542
+ wait
41543
+ });
41544
+ } else this.groupNativeFailed.delete(linkId);
41545
+ } else this.groupNativeFailed.delete(linkId);
41476
41546
  this.emitState();
41477
41547
  });
41478
41548
  }
41479
- /** One transport's listener for one chat, in that transport's queue. True when a group's link found no slot free. */
41549
+ /**
41550
+ * One transport's listener for one chat, in that transport's queue. "no-slot" when a group's link found no slot free,
41551
+ * "failed" when the listener could not start.
41552
+ */
41480
41553
  async startNativeEndpoint(linkId, expected, key, factory, inUse) {
41481
41554
  const live = this.links.get(linkId), link = live?.link;
41482
41555
  if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
@@ -41486,7 +41559,7 @@ var GhostlyNode = class GhostlyNode {
41486
41559
  live.transportErrors ??= {};
41487
41560
  try {
41488
41561
  const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
41489
- if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return true;
41562
+ if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return "no-slot";
41490
41563
  if (owners.length >= NATIVE_SLOTS) {
41491
41564
  const now = Date.now();
41492
41565
  const taking = !group && (this.activeLinkId === linkId || inUse);
@@ -41530,7 +41603,12 @@ var GhostlyNode = class GhostlyNode {
41530
41603
  transportSeeds
41531
41604
  };
41532
41605
  await db.patchLink(linkId, { transportSeeds });
41533
- const endpoint = await factory(seed);
41606
+ const starting = factory(seed);
41607
+ const endpoint = await Promise.race([starting, this.stopping]);
41608
+ if (!endpoint) {
41609
+ starting.then((late) => late.close(), () => {});
41610
+ return false;
41611
+ }
41534
41612
  if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
41535
41613
  await endpoint.close();
41536
41614
  return false;
@@ -41541,6 +41619,7 @@ var GhostlyNode = class GhostlyNode {
41541
41619
  this.emitState();
41542
41620
  } catch (error) {
41543
41621
  live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
41622
+ return "failed";
41544
41623
  }
41545
41624
  return false;
41546
41625
  }
@@ -41611,8 +41690,23 @@ var GhostlyNode = class GhostlyNode {
41611
41690
  /** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
41612
41691
  groupNativeWaiting = /* @__PURE__ */ new Set();
41613
41692
  groupNativeRetryAt = 0;
41614
- /** Now and then, the group links that found no free native slot try again: a chat or another group may have let one go. */
41693
+ /** Group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
41694
+ groupNativeFailed = /* @__PURE__ */ new Map();
41695
+ /**
41696
+ * Now and then, the group links that found no free native slot try again: a chat or another group may have let one go.
41697
+ * Those whose listener failed to start try again once their wait is over.
41698
+ */
41615
41699
  retryGroupNative() {
41700
+ const now = Date.now();
41701
+ for (const [linkId, failed] of this.groupNativeFailed) {
41702
+ if (now < failed.at) continue;
41703
+ if (!this.links.get(linkId)?.link) {
41704
+ this.groupNativeFailed.delete(linkId);
41705
+ continue;
41706
+ }
41707
+ failed.at = now + failed.wait;
41708
+ this.ensureNativeEndpoints(linkId);
41709
+ }
41616
41710
  if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
41617
41711
  this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
41618
41712
  for (const linkId of [...this.groupNativeWaiting]) {
@@ -41813,7 +41907,7 @@ var GhostlyNode = class GhostlyNode {
41813
41907
  },
41814
41908
  ...this.reactionNotes.has(stored.id) && { lastReaction: this.reactionNotes.get(stored.id) },
41815
41909
  ...stored.pin && { pin: pinView(stored.pin) },
41816
- participationKey: stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0,
41910
+ participationKey: participationKeyOf(live),
41817
41911
  peerParticipationKey: stored.pairedPeerKey,
41818
41912
  publicProfiles: void 0,
41819
41913
  profileChoice: void 0,
@@ -41930,6 +42024,9 @@ var EngineServer = class {
41930
42024
  * call (WISP 06 § States and events). A client that goes away (a tab closed mid-call) is taken out with it.
41931
42025
  */
41932
42026
  onCall = /* @__PURE__ */ new Set();
42027
+ /** How many history changes the engine told; per chat or group, the count at its last one (`historyMark`). */
42028
+ changes = 0;
42029
+ changedAt = /* @__PURE__ */ new Map();
41933
42030
  constructor(options = {}) {
41934
42031
  this.node = new GhostlyNode({
41935
42032
  onAttention: (event) => this.broadcast({
@@ -41940,8 +42037,14 @@ var EngineServer = class {
41940
42037
  kind: "state",
41941
42038
  state
41942
42039
  }),
41943
- onMessages: (linkId, messages) => void this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {}),
41944
- onMessageChanges: (linkId, changes) => void this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {}),
42040
+ onMessages: (linkId, messages) => {
42041
+ this.changed(linkId);
42042
+ this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {});
42043
+ },
42044
+ onMessageChanges: (linkId, changes) => {
42045
+ this.changed(linkId);
42046
+ this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {});
42047
+ },
41945
42048
  onCallSignal: (linkId, signal) => {
41946
42049
  if (signalKind(signal) === "o") this.offers.set(linkId, signal);
41947
42050
  else this.offers.delete(linkId);
@@ -41965,9 +42068,21 @@ var EngineServer = class {
41965
42068
  this.ready = this.node.start();
41966
42069
  this.ready.catch(() => {});
41967
42070
  }
41968
- attach(client) {
42071
+ /** A point in the engine's history changes: a history read after it can be handed to `attach` as read. */
42072
+ get historyMark() {
42073
+ return this.changes;
42074
+ }
42075
+ /**
42076
+ * `read`: the chats and groups whose whole history the client read itself, after `mark` (`historyMark`). One that has
42077
+ * not changed since is not read and sent again: the client hears only what changes in it from now on.
42078
+ */
42079
+ attach(client, read) {
41969
42080
  this.clients.add(client);
41970
- this.histories.set(client, /* @__PURE__ */ new Set());
42081
+ const sent = /* @__PURE__ */ new Set();
42082
+ if (read) {
42083
+ for (const id of read.ids) if ((this.changedAt.get(id) ?? 0) <= read.mark) sent.add(id);
42084
+ }
42085
+ this.histories.set(client, sent);
41971
42086
  this.ready.then(async () => {
41972
42087
  if (!this.clients.has(client)) return;
41973
42088
  client.post({
@@ -41980,15 +42095,18 @@ var EngineServer = class {
41980
42095
  signal
41981
42096
  });
41982
42097
  else this.offers.delete(linkId);
41983
- for (const link of this.node.getState().links) await this.inOrder(link.id, async () => {
41984
- if (!this.clients.has(client)) return;
41985
- client.post({
41986
- kind: "messages",
41987
- linkId: link.id,
41988
- messages: await this.node.getMessages(link.id)
42098
+ for (const link of this.node.getState().links) {
42099
+ if (sent.has(link.id)) continue;
42100
+ await this.inOrder(link.id, async () => {
42101
+ if (!this.clients.has(client)) return;
42102
+ client.post({
42103
+ kind: "messages",
42104
+ linkId: link.id,
42105
+ messages: await this.node.getMessages(link.id)
42106
+ });
42107
+ this.histories.get(client)?.add(link.id);
41989
42108
  });
41990
- this.histories.get(client)?.add(link.id);
41991
- });
42109
+ }
41992
42110
  }, (error) => {
41993
42111
  this.post(client, {
41994
42112
  kind: "start-failed",
@@ -42050,6 +42168,9 @@ var EngineServer = class {
42050
42168
  return Promise.reject(error);
42051
42169
  }
42052
42170
  }
42171
+ changed(linkId) {
42172
+ this.changedAt.set(linkId, ++this.changes);
42173
+ }
42053
42174
  track(linkId, running) {
42054
42175
  const settled = running.catch(() => {});
42055
42176
  this.sending.set(linkId, settled);
@@ -42115,4 +42236,4 @@ function signalKind(signal) {
42115
42236
  //#endregion
42116
42237
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
42117
42238
 
42118
- //# sourceMappingURL=server-HMzdsyn_.js.map
42239
+ //# sourceMappingURL=server-BGXmSYx_.js.map