@ghostlytools/cli 1.1.7 → 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 (35) 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/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
  6. package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
  7. package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
  8. package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
  9. package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
  10. package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
  11. package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
  12. package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
  13. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
  14. package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
  15. package/dist/assets/{hyperdhtRelay-DvXgebV7.js → hyperdhtRelay-DmTrpmba.js} +3 -3
  16. package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-DmTrpmba.js.map} +1 -1
  17. package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
  18. package/dist/assets/idb-CTF6LF5B.js.map +1 -0
  19. package/dist/assets/{mainline-B7wHcLaW.js → mainline-BueFJx3f.js} +2 -2
  20. package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-BueFJx3f.js.map} +1 -1
  21. package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
  22. package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
  23. package/dist/assets/{server-GJW_5wGI.js → server-BGXmSYx_.js} +1124 -1036
  24. package/dist/assets/server-BGXmSYx_.js.map +1 -0
  25. package/dist/assets/{src-Cp3o_yHN.js → src-BMUcYQ7b.js} +750 -242
  26. package/dist/assets/src-BMUcYQ7b.js.map +1 -0
  27. package/dist/assets/usdt-DklGBSeq.js +410 -0
  28. package/dist/assets/usdt-DklGBSeq.js.map +1 -0
  29. package/dist/ghostly.mjs +858 -137
  30. package/dist/ghostly.mjs.map +1 -1
  31. package/package.json +4 -4
  32. package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
  33. package/dist/assets/idb-BIdus0WX.js.map +0 -1
  34. package/dist/assets/server-GJW_5wGI.js.map +0 -1
  35. package/dist/assets/src-Cp3o_yHN.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-Cp3o_yHN.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);
@@ -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
  }
@@ -11903,6 +11151,16 @@ var STAGED = {
11903
11151
  /** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
11904
11152
  var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
11905
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
+ }
11906
11164
  /** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
11907
11165
  function fail(code, words) {
11908
11166
  throw new Error(`${code}: ${words}`);
@@ -11921,6 +11179,15 @@ var versionOf = (manifest, digest) => ({
11921
11179
  digest
11922
11180
  });
11923
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
+ }
11924
11191
  /** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
11925
11192
  var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
11926
11193
  function done(tx) {
@@ -11957,13 +11224,28 @@ var Apps = class {
11957
11224
  staged = /* @__PURE__ */ new Map();
11958
11225
  /** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
11959
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();
11960
11234
  /** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
11961
11235
  usage = /* @__PURE__ */ new Map();
11962
11236
  queues = /* @__PURE__ */ new Map();
11963
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;
11964
11240
  checking = null;
11965
11241
  /** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
11966
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();
11967
11249
  stopped = false;
11968
11250
  constructor(host) {
11969
11251
  this.host = host;
@@ -11984,6 +11266,9 @@ var Apps = class {
11984
11266
  this.stopped = true;
11985
11267
  if (this.timer) clearTimeout(this.timer);
11986
11268
  this.timer = null;
11269
+ if (this.sweep) clearTimeout(this.sweep);
11270
+ this.sweep = null;
11271
+ this.staged.clear();
11987
11272
  }
11988
11273
  schedule(ms, atStart = false) {
11989
11274
  if (this.timer) clearTimeout(this.timer);
@@ -12028,7 +11313,7 @@ var Apps = class {
12028
11313
  * or not the page stops its frame.
12029
11314
  */
12030
11315
  async stillRunnable(app) {
12031
- const run = this.runStatus(app, await this.stores(), app.revocations);
11316
+ const run = this.runStatus(app, await this.heldViews(), app.revocations);
12032
11317
  if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
12033
11318
  if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12034
11319
  return app;
@@ -12047,11 +11332,42 @@ var Apps = class {
12047
11332
  async putApp(app) {
12048
11333
  await wrap((await store(STORES$1.apps, "readwrite")).put(app));
12049
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
+ }
12050
11351
  async storeRecord(key) {
12051
11352
  return wrap((await store(STORES$1.appStores, "readonly")).get(key));
12052
11353
  }
12053
11354
  async putStore(record) {
12054
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
+ });
12055
11371
  }
12056
11372
  /** The stores the person has (a removed default is not one). */
12057
11373
  async stores() {
@@ -12073,8 +11389,8 @@ var Apps = class {
12073
11389
  kind: s.index.kind
12074
11390
  }));
12075
11391
  }
12076
- runStatus(version, stores, revocations = []) {
12077
- const check = checkAppBeforeRun(version, this.storeViews(stores), revocations);
11392
+ runStatus(version, views, revocations = []) {
11393
+ const check = checkAppBeforeRun(version, views, revocations);
12078
11394
  if (check.status === "revoked") return {
12079
11395
  status: "revoked",
12080
11396
  ...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
@@ -12133,19 +11449,34 @@ var Apps = class {
12133
11449
  }
12134
11450
  async removeBundle(kind, digest) {
12135
11451
  this.verified.delete(digest);
11452
+ this.icons.delete(digest);
12136
11453
  await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
12137
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
+ }
12138
11460
  /** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
12139
- async verifiedBundle(app) {
11461
+ verifiedBundle(app) {
12140
11462
  const kept = this.verified.get(app.digest);
12141
- if (kept) return kept;
12142
- const bytes = await this.readBundleBytes(app.bytes, app.digest);
12143
- if (!bytes) fail("needs-files", "This app's files are not on this device yet");
12144
- const read = readAppBundle(bytes);
12145
- if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
12146
- if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
12147
- this.verified.set(app.digest, read.bundle);
12148
- 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;
12149
11480
  }
12150
11481
  async fetchBundle(url) {
12151
11482
  const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
@@ -12156,17 +11487,36 @@ var Apps = class {
12156
11487
  bytes
12157
11488
  };
12158
11489
  }
12159
- 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) {
12160
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
+ };
12161
11504
  return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
12162
11505
  }
12163
- /** The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error. */
12164
- async readRevocations(ref, urls) {
12165
- const out = [];
12166
- for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) try {
12167
- const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
12168
- if (read.ok) out.push(...read.revocations.filter((r) => r.statement.app === ref));
12169
- } 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
+ }
12170
11520
  return out;
12171
11521
  }
12172
11522
  summary(s) {
@@ -12299,7 +11649,12 @@ var Apps = class {
12299
11649
  preloaded: true,
12300
11650
  removed: true
12301
11651
  });
12302
- 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
+ }
12303
11658
  }
12304
11659
  /** Reads one store again, or every store (the person opened the Apps page, or the update check). */
12305
11660
  async refreshStores({ key } = {}) {
@@ -12309,7 +11664,7 @@ var Apps = class {
12309
11664
  for (const s of stores) {
12310
11665
  let next;
12311
11666
  try {
12312
- const read = await this.readStoreAt(s.url, s.key);
11667
+ const read = await this.readStoreAt(s.url, s.key, s);
12313
11668
  next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
12314
11669
  ...s,
12315
11670
  problem: read.reason
@@ -12328,23 +11683,28 @@ var Apps = class {
12328
11683
  }
12329
11684
  /**
12330
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
12331
- * 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
12332
- * 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`.
12333
11690
  */
12334
11691
  async preview(source) {
12335
11692
  let urls;
12336
11693
  let expect = null;
11694
+ let fromStore;
12337
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")];
12338
11696
  else if ("store" in source) {
12339
11697
  const s = await this.storeRecord(source.store);
12340
11698
  const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
12341
11699
  if (!listing) fail("not-listed", "This store does not list this app");
12342
- urls = listing.urls.filter(isAppFetchUrl);
11700
+ urls = listingUrls(listing.urls);
12343
11701
  expect = {
12344
11702
  ref: listing.ref,
12345
11703
  sequence: listing.sequence,
12346
- digest: listing.digest
11704
+ digest: listing.digest,
11705
+ exact: true
12347
11706
  };
11707
+ fromStore = s.key;
12348
11708
  } else if ("card" in source && source.card) {
12349
11709
  const { ref, url, sequence, digest } = source.card;
12350
11710
  if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
@@ -12365,14 +11725,22 @@ var Apps = class {
12365
11725
  if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
12366
11726
  if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
12367
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");
12368
11729
  }
12369
- return this.stage(bundle, bytes, url);
11730
+ return this.stage(bundle, bytes, url, fromStore);
12370
11731
  } catch (error) {
12371
11732
  last = error;
12372
11733
  }
12373
11734
  throw last instanceof Error ? last : new Error(String(last));
12374
11735
  }
12375
- 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) {
12376
11744
  for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
12377
11745
  this.staged.delete(bundle.digest);
12378
11746
  while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
@@ -12380,8 +11748,10 @@ var Apps = class {
12380
11748
  bundle,
12381
11749
  bytes,
12382
11750
  from,
11751
+ ...fromStore !== void 0 && { store: fromStore },
12383
11752
  at: this.now()
12384
11753
  });
11754
+ if (!this.sweep) this.sweepStaged();
12385
11755
  const { manifest, digest } = bundle;
12386
11756
  const version = versionOf(manifest, digest);
12387
11757
  const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
@@ -12399,7 +11769,7 @@ var Apps = class {
12399
11769
  icon: bundle.files.get("icon.png")?.slice() ?? null,
12400
11770
  install,
12401
11771
  asks,
12402
- run: this.runStatus(version, stores, installed?.revocations),
11772
+ run: this.runStatus(version, this.storeViews(stores), installed?.revocations),
12403
11773
  listedBy,
12404
11774
  unknownPublisher: !listedBy.some((s) => s.kind === "curated")
12405
11775
  };
@@ -12415,12 +11785,12 @@ var Apps = class {
12415
11785
  this.staged.delete(digest);
12416
11786
  fail("expired", "Fetch the app again");
12417
11787
  }
12418
- const { bundle, bytes, from } = staged;
11788
+ const { bundle, bytes, from, store: fromStore } = staged;
12419
11789
  const { manifest } = bundle;
12420
11790
  if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
12421
11791
  const version = versionOf(manifest, digest);
12422
11792
  const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
12423
- const run = this.runStatus(version, stores, installed?.revocations);
11793
+ const run = this.runStatus(version, this.storeViews(stores), installed?.revocations);
12424
11794
  if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12425
11795
  if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12426
11796
  const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
@@ -12438,26 +11808,37 @@ var Apps = class {
12438
11808
  fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
12439
11809
  }
12440
11810
  if (decision === "same" && installed) {
12441
- if (!await this.hasBundle(installed.bytes, digest)) await this.putApp({
12442
- ...installed,
12443
- bytes: await this.writeBundle(digest, bytes)
12444
- });
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
+ }
12445
11822
  this.staged.delete(digest);
12446
11823
  return this.view(await this.app(version.ref), stores);
12447
11824
  }
12448
11825
  const kind = await this.writeBundle(digest, bytes);
12449
- const next = this.versionRecord(installed, bundle, kind, from);
11826
+ const next = this.versionRecord(installed, bundle, kind, from, fromStore);
12450
11827
  await this.putApp(next);
12451
11828
  if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
12452
- 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);
12453
11830
  this.staged.delete(digest);
12454
11831
  return this.view(next, stores);
12455
11832
  }
12456
- /** The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). */
12457
- 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) {
12458
11839
  const { manifest, digest } = bundle;
12459
11840
  const now = this.now();
12460
- 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;
12461
11842
  return {
12462
11843
  ref: appRef(manifest.publisher, manifest.name),
12463
11844
  publisher: manifest.publisher,
@@ -12468,6 +11849,7 @@ var Apps = class {
12468
11849
  manifest,
12469
11850
  bytes: kind,
12470
11851
  from,
11852
+ ...store !== void 0 && { store },
12471
11853
  installedAt: installed?.installedAt ?? now,
12472
11854
  updatedAt: now,
12473
11855
  ...installed?.revocations?.length && { revocations: installed.revocations },
@@ -12488,7 +11870,8 @@ var Apps = class {
12488
11870
  fail("needs-files", "The update's files are gone; it is fetched again at the next check");
12489
11871
  }
12490
11872
  const stores = await this.stores();
12491
- 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);
12492
11875
  if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
12493
11876
  if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
12494
11877
  const plan = planAppUpdate(installed, {
@@ -12497,15 +11880,20 @@ var Apps = class {
12497
11880
  });
12498
11881
  if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
12499
11882
  const { pending: _p, ...without } = installed;
12500
- 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);
12501
11884
  await this.putApp(next);
12502
11885
  await this.removeBundle(installed.bytes, installed.digest);
12503
11886
  return this.view(next, stores);
12504
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
+ }
12505
11892
  /**
12506
11893
  * The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
12507
- * app's newer versions looked for in the stores' listings and its manifest's `sources`, and its publisher's
12508
- * 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
12509
11897
  * some waits for the person. One check at a time.
12510
11898
  */
12511
11899
  checkUpdates() {
@@ -12531,21 +11919,65 @@ var Apps = class {
12531
11919
  if (!first) continue;
12532
11920
  let app = first;
12533
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
+ }
12534
11932
  const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
12535
11933
  const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
12536
- 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));
12537
11937
  for (const s of stores) {
11938
+ if (pinned !== void 0 && s.key !== pinned) continue;
12538
11939
  const listing = s.index?.apps.find((a) => a.ref === app.ref);
12539
- 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
+ }
12540
11951
  }
12541
- for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) try {
12542
- const peek = peekAppManifest(await this.host.fetch(url, {
12543
- maxBytes: PEEK_BYTES,
12544
- peek: true
12545
- }));
12546
- if (peek && peek.ref === app.ref && (peek.sequence > highest || peek.sequence >= app.sequence && !known.has(peek.digest))) candidates.push(url);
12547
- } catch {}
12548
- 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
+ }
12549
11981
  let fetched;
12550
11982
  try {
12551
11983
  fetched = await this.fetchBundle(url);
@@ -12555,6 +11987,7 @@ var Apps = class {
12555
11987
  const { bundle, bytes } = fetched;
12556
11988
  const version = versionOf(bundle.manifest, bundle.digest);
12557
11989
  if (version.ref !== app.ref || known.has(version.digest)) continue;
11990
+ if (want.digest !== void 0 && version.digest !== want.digest) continue;
12558
11991
  if (app.pending && version.sequence === app.pending.sequence) {
12559
11992
  app = {
12560
11993
  ...app,
@@ -12585,17 +12018,21 @@ var Apps = class {
12585
12018
  }
12586
12019
  continue;
12587
12020
  }
12588
- if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
12021
+ if (this.runStatus(version, this.storeViews(stores), app.revocations).status !== "ok") continue;
12589
12022
  if (app.pending && version.sequence < app.pending.sequence) continue;
12590
12023
  const kind = await this.writeBundle(version.digest, bytes);
12024
+ wrote = {
12025
+ bytes: kind,
12026
+ digest: version.digest
12027
+ };
12591
12028
  if (plan.action === "install") {
12592
12029
  const old = app;
12593
- app = this.versionRecord(app, bundle, kind, url);
12594
- if (old.pending && old.pending.sequence <= version.sequence) {
12595
- await this.removeBundle(old.pending.bytes, old.pending.digest);
12596
- delete app.pending;
12597
- }
12598
- 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);
12599
12036
  await this.removeBundle(old.bytes, old.digest);
12600
12037
  outcome = "updated";
12601
12038
  } else {
@@ -12616,16 +12053,24 @@ var Apps = class {
12616
12053
  }
12617
12054
  break;
12618
12055
  }
12619
- const revocations = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []]);
12620
- const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
12621
- for (const r of revocations) merged.set(canonicalJson(r), r);
12622
- 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
+ }
12623
12065
  app = {
12624
12066
  ...app,
12625
12067
  ...merged.size && { revocations: [...merged.values()] },
12626
12068
  checkedAt: this.now()
12627
12069
  };
12628
- 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
+ }
12629
12074
  out.push({
12630
12075
  ref: app.ref,
12631
12076
  outcome,
@@ -12638,15 +12083,19 @@ var Apps = class {
12638
12083
  * Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
12639
12084
  * carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
12640
12085
  * taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
12641
- * 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.
12642
12088
  */
12643
12089
  async fetchFiles({ ref }) {
12644
12090
  const app = await this.installedOrFail(ref);
12645
12091
  const stores = await this.stores();
12646
12092
  if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
12647
12093
  const urls = [app.from];
12648
- 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);
12649
- 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 ?? []);
12650
12099
  for (const url of new Set(urls.filter(isAppFetchUrl))) {
12651
12100
  let fetched;
12652
12101
  try {
@@ -12674,7 +12123,7 @@ var Apps = class {
12674
12123
  }
12675
12124
  async runCheckOf(app, stores) {
12676
12125
  if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
12677
- return this.runStatus(app, stores, app.revocations);
12126
+ return this.runStatus(app, this.storeViews(stores), app.revocations);
12678
12127
  }
12679
12128
  /** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
12680
12129
  async runCheck({ ref }) {
@@ -12710,6 +12159,9 @@ var Apps = class {
12710
12159
  /** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
12711
12160
  async file({ ref, path }) {
12712
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");
12713
12165
  const bundle = await this.verifiedBundle(app);
12714
12166
  if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
12715
12167
  return bundle.files.get(path).slice();
@@ -12826,7 +12278,12 @@ var Apps = class {
12826
12278
  name
12827
12279
  ])))?.size ?? 0) + size;
12828
12280
  if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
12829
- 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({
12830
12287
  ref: app.ref,
12831
12288
  scope: where,
12832
12289
  key: name,
@@ -12871,7 +12328,12 @@ var Apps = class {
12871
12328
  scope: row.scope,
12872
12329
  entries: {}
12873
12330
  };
12874
- 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
+ });
12875
12337
  scopes.set(row.scope, out);
12876
12338
  }
12877
12339
  return [...scopes.values()];
@@ -13197,7 +12659,26 @@ function parseDeviceRecord(value) {
13197
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");
13198
12660
  if (!Array.isArray(o.sources) || o.sources.length > 16 || !o.sources.every(text)) return bad("ownSet");
13199
12661
  }
13200
- 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
+ };
13201
12682
  }
13202
12683
  function checked(record) {
13203
12684
  return parseDeviceRecord(record);
@@ -14982,6 +14463,8 @@ async function openTurnKeeper(profile, network, options = {}) {
14982
14463
  signer: (bytes) => identity.key.sign(bytes)
14983
14464
  });
14984
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;
14985
14468
  /** How long, and how often, the joiner reads the turn record for the record that lists it. */
14986
14469
  var ENROLL_FINISH = {
14987
14470
  rounds: 20,
@@ -15692,6 +15175,11 @@ function ghostLinkEnrollChannel(options) {
15692
15175
  };
15693
15176
  let link = null;
15694
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;
15695
15183
  const tryOpen = () => {
15696
15184
  if (opened || !link?.supportsDevice("enroll/1")) return;
15697
15185
  const hash = link.sessionTranscriptHash, key = credentials.peerKey;
@@ -15714,7 +15202,10 @@ function ghostLinkEnrollChannel(options) {
15714
15202
  native: { automatic: true },
15715
15203
  packetTransports: true,
15716
15204
  transport: options.transport,
15717
- pollIntervals: options.pollIntervals,
15205
+ pollIntervals: {
15206
+ ...intervals,
15207
+ connected: intervals.active
15208
+ },
15718
15209
  autoConnect: true,
15719
15210
  createPeerConnection: () => {
15720
15211
  if (!options.createPeerConnection) throw new ReferenceError("RTCPeerConnection is not defined");
@@ -15746,7 +15237,11 @@ function ghostLinkEnrollChannel(options) {
15746
15237
  },
15747
15238
  onPeerKeyRefused: () => events.onPeerRefused(),
15748
15239
  onPresence: (presence) => {
15749
- 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(() => {});
15750
15245
  },
15751
15246
  onPacketTransports: (transports) => {
15752
15247
  if (!transports.includes("webrtc/1")) startNative();
@@ -15778,6 +15273,7 @@ function ghostLinkEnrollChannel(options) {
15778
15273
  stop: async () => {
15779
15274
  const running = link;
15780
15275
  link = null;
15276
+ if (reput) clearInterval(reput);
15781
15277
  await running?.stop().catch(() => {});
15782
15278
  }
15783
15279
  };
@@ -18736,7 +18232,7 @@ async function room() {
18736
18232
  async function handoffSelf(profileHost) {
18737
18233
  return {
18738
18234
  app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
18739
- db: 14,
18235
+ db: 15,
18740
18236
  pins: { ...WALLET_SDK_PINS },
18741
18237
  kind: profileHost.kind,
18742
18238
  room: await room(),
@@ -20779,6 +20275,7 @@ var Outbox = class {
20779
20275
  return this.turn(async () => {});
20780
20276
  }
20781
20277
  async requeueUnconfirmed() {
20278
+ if (!this.receipts.size) return;
20782
20279
  const messages = await this.store.read();
20783
20280
  for (const id of [...this.receipts.keys()]) {
20784
20281
  if (messages.find((m) => m.id === id)?.via === "pkarr") {
@@ -20898,6 +20395,24 @@ var days = (ms) => {
20898
20395
  };
20899
20396
  //#endregion
20900
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}`;
20901
20416
  var EditQueue = class {
20902
20417
  deps;
20903
20418
  pace;
@@ -20905,6 +20420,8 @@ var EditQueue = class {
20905
20420
  now;
20906
20421
  /** Per message: the edit number last sent, when, and how many times that number went. */
20907
20422
  sent = /* @__PURE__ */ new Map();
20423
+ /** When a card that goes again last went (`CARD_RESTORE_GAP_MS`). */
20424
+ restoredAt = -Infinity;
20908
20425
  timer;
20909
20426
  flushing = null;
20910
20427
  again = false;
@@ -20971,7 +20488,14 @@ var EditQueue = class {
20971
20488
  if (this.stopped) return;
20972
20489
  const now = this.now();
20973
20490
  const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
20974
- 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;
20975
20499
  let next = Infinity;
20976
20500
  for (const message of due) {
20977
20501
  const edit = message.edit;
@@ -20980,6 +20504,11 @@ var EditQueue = class {
20980
20504
  await this.deps.settle(message.id, edit.seq);
20981
20505
  continue;
20982
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
+ }
20983
20512
  if (message.delivery === "waiting") continue;
20984
20513
  if (!this.deps.ready()) return;
20985
20514
  const last = this.sent.get(message.id);
@@ -20992,6 +20521,10 @@ var EditQueue = class {
20992
20521
  continue;
20993
20522
  }
20994
20523
  }
20524
+ if (edit.restore && this.restoredAt + gap > now) {
20525
+ next = Math.min(next, this.restoredAt + gap);
20526
+ continue;
20527
+ }
20995
20528
  const pace = this.pace.wait();
20996
20529
  if (pace > 0) {
20997
20530
  next = Math.min(next, now + pace);
@@ -21007,6 +20540,7 @@ var EditQueue = class {
21007
20540
  ...message.preview && { pv: message.preview },
21008
20541
  ...message.card && { sc: message.card }
21009
20542
  }, message)) continue;
20543
+ if (edit.restore) this.restoredAt = now;
21010
20544
  this.sent.set(message.id, {
21011
20545
  seq: edit.seq,
21012
20546
  at: now,
@@ -21616,18 +21150,35 @@ var db = {
21616
21150
  },
21617
21151
  async deleteLink(linkId) {
21618
21152
  await wrap((await store(STORES$1.links, "readwrite")).delete(linkId));
21619
- const messages = await store(STORES$1.messages, "readwrite");
21620
- const keys = await wrap(messages.index("byLink").getAllKeys(linkId));
21621
- await Promise.all(keys.map((key) => wrap(messages.delete(key))));
21153
+ await wrap((await store(STORES$1.messages, "readwrite")).delete(IDBKeyRange.bound([linkId], [linkId, []])));
21622
21154
  await fileStore.deleteForLink(linkId);
21623
21155
  await removeFileBytes(`${linkId}-in-`);
21624
21156
  await removeFileBytes(`${linkId}-out-`);
21625
21157
  const appStorage = await store(STORES$1.appStorage, "readwrite");
21626
- const appKeys = await wrap(appStorage.index(APP_STORAGE_CHAT_INDEX).getAllKeys(linkId));
21627
- 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
+ });
21628
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
+ */
21629
21180
  async getMessages(linkId) {
21630
- 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);
21631
21182
  },
21632
21183
  /** Whether the chat has a message with this id. */
21633
21184
  async hasMessage(linkId, id) {
@@ -21824,9 +21375,10 @@ function noteAfterChange(current, message) {
21824
21375
  };
21825
21376
  }
21826
21377
  /**
21827
- * My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`, of
21828
- * this membership only. One made before I was removed and invited again went out under my old member key; said again
21829
- * 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.
21830
21382
  */
21831
21383
  function groupReactionsToResend(messages, limit) {
21832
21384
  let joinedAt = 0;
@@ -21838,7 +21390,7 @@ function groupReactionsToResend(messages, limit) {
21838
21390
  e: r.e,
21839
21391
  n: r.n
21840
21392
  }] : [];
21841
- }).sort((a, b) => b.n - a.n).slice(0, limit).reverse();
21393
+ }).sort((a, b) => b.n - a.n).slice(0, limit);
21842
21394
  }
21843
21395
  /** This side's highest number in a chat: the next one goes past it (and past what still waits to be confirmed). */
21844
21396
  function lastMine(messages, pending = []) {
@@ -21849,7 +21401,7 @@ function lastMine(messages, pending = []) {
21849
21401
  }
21850
21402
  var Reactions = class {
21851
21403
  host;
21852
- /** 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). */
21853
21405
  waiting = /* @__PURE__ */ new Map();
21854
21406
  /** One chat's reactions at a time: a burst for one message lands in order. */
21855
21407
  queue = Promise.resolve();
@@ -21895,7 +21447,7 @@ var Reactions = class {
21895
21447
  }
21896
21448
  /**
21897
21449
  * A reaction that came from `by` (`peer`, or a member's key), already authenticated as theirs. A message not here
21898
- * 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).
21899
21451
  */
21900
21452
  receive(chat, by, reaction) {
21901
21453
  if (isGroup$1(chat)) {
@@ -21922,21 +21474,22 @@ var Reactions = class {
21922
21474
  const now = this.now(), all = (this.waiting.get(message.linkId) ?? []).filter((w) => w.until > now);
21923
21475
  const mine = all.filter((w) => w.reaction.id === id).sort((a, b) => a.reaction.n - b.reaction.n);
21924
21476
  this.waiting.set(message.linkId, all.filter((w) => w.reaction.id !== id));
21925
- for (const { by, reaction } of mine) {
21926
- const target = reactionTarget(await this.host.messages(message.linkId), id, isGroup$1(message.linkId));
21927
- if (target && reactionIsNewer(target.reactions?.[by], reaction.n)) await this.write(message.linkId, target, by, reaction);
21928
- }
21477
+ for (const { by, reaction } of mine) await this.write(message.linkId, message, by, reaction);
21929
21478
  });
21930
21479
  }
21931
21480
  /**
21932
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
21933
- * 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.
21934
21484
  */
21935
21485
  async find(chat, id) {
21936
21486
  const group = isGroup$1(chat);
21937
- if (this.host.message) for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
21938
- const row = await this.host.message(chat, rowId);
21939
- 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;
21940
21493
  }
21941
21494
  return reactionTarget(await this.host.messages(chat), id, group);
21942
21495
  }
@@ -21951,7 +21504,7 @@ var Reactions = class {
21951
21504
  if (same && same.reaction.n >= reaction.n) return "stale";
21952
21505
  const kept = list.filter((w) => w !== same);
21953
21506
  this.waiting.set(chat, kept);
21954
- if (kept.length >= REACTION_LIMITS.buffer) return "dropped";
21507
+ if (kept.filter((w) => w.by === by).length >= REACTION_LIMITS.buffer) return "dropped";
21955
21508
  kept.push({
21956
21509
  by,
21957
21510
  reaction,
@@ -22117,11 +21670,14 @@ var HubClocks = class {
22117
21670
  };
22118
21671
  //#endregion
22119
21672
  //#region ../browser/src/engine/community.ts
22120
- /** The line a change of a group's picture leaves in its history (both profiles). */
22121
- 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`;
22122
21675
  /** The line a new name leaves (both profiles): `group` is the name the group has now. */
22123
- var renameText = (name, group) => `${name} renamed the group to “${group}”`;
22124
- /** 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
+ */
22125
21681
  function metaLines(name, change, group) {
22126
21682
  return [..."name" in change ? [{
22127
21683
  event: "renamed",
@@ -22511,7 +22067,8 @@ var Communities = class {
22511
22067
  await this.store.putGroup(group);
22512
22068
  this.host.emit();
22513
22069
  }
22514
- 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) {
22515
22072
  const live = this.live.get(groupId);
22516
22073
  if (!live) return {
22517
22074
  error: "You are not in this group yet",
@@ -22521,6 +22078,12 @@ var Communities = class {
22521
22078
  error: live.session.state.statusReason ?? "You are not in this group",
22522
22079
  refused: true
22523
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);
22524
22087
  const now = this.now();
22525
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);
22526
22089
  return "error" in result ? { error: result.error } : {
@@ -22650,6 +22213,7 @@ var Communities = class {
22650
22213
  this.knockRead.delete(groupId);
22651
22214
  for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
22652
22215
  await this.store.deleteGroup(groupId);
22216
+ await this.files?.drop(groupId);
22653
22217
  this.host.historyGone?.(groupId);
22654
22218
  this.host.emit();
22655
22219
  }
@@ -23403,6 +22967,16 @@ var Communities = class {
23403
22967
  }
23404
22968
  }
23405
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
+ /**
23406
22980
  * The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
23407
22981
  * link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
23408
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.
@@ -23525,6 +23099,10 @@ var Communities = class {
23525
23099
  await this.store.putGroup(member);
23526
23100
  if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
23527
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(() => {});
23528
23106
  this.lastKnock.delete(g);
23529
23107
  this.knocked.delete(g);
23530
23108
  this.knockAt.delete(g);
@@ -23672,7 +23250,7 @@ var Communities = class {
23672
23250
  this.membershipChanged(id);
23673
23251
  },
23674
23252
  metaChanged: (by, change, at) => {
23675
- 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)}`;
23676
23254
  const when = receivedTimestamp(at, this.now());
23677
23255
  (async () => {
23678
23256
  for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
@@ -23869,7 +23447,8 @@ var MeshHubs = class {
23869
23447
  primed: false,
23870
23448
  gone: /* @__PURE__ */ new Map(),
23871
23449
  reactions: /* @__PURE__ */ new Map(),
23872
- need: 0
23450
+ need: 0,
23451
+ departed: /* @__PURE__ */ new Set()
23873
23452
  });
23874
23453
  }
23875
23454
  return live;
@@ -23921,10 +23500,36 @@ var MeshHubs = class {
23921
23500
  const live = this.live.get(groupId);
23922
23501
  if (!live || !this.large(session)) return [];
23923
23502
  const policy = session.hubPolicy, inRoster = (key) => rosterHas(session.roster, key);
23924
- const out = new Set(meshHubs(live.beacon, session.myKey, policy, inRoster, now).map((h) => h.key));
23925
- 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);
23926
23505
  return [...out];
23927
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
+ }
23928
23533
  /** The group runs on hubs: it is large, and I am a hub or know of one. Else it is the full mesh. */
23929
23534
  active(groupId, session, now) {
23930
23535
  return this.large(session) && (this.isHub(groupId) || this.hubs(groupId, session, now).length > 0);
@@ -24021,7 +23626,7 @@ var MeshHubs = class {
24021
23626
  const may = mayBeHub(me, policy, inRoster, this.staysOnline);
24022
23627
  const readEvery = live.hub || may ? this.timings.beaconEveryMs : this.timings.beaconReadMs;
24023
23628
  if (!live.lastRead || now - live.lastRead >= readEvery) await this.read(groupId, session, live, now);
24024
- const others = meshHubs(live.beacon, me, policy, inRoster, now);
23629
+ const others = this.listed(live, session, now);
24025
23630
  const fits = this.fits(groupId, session);
24026
23631
  if (live.hub) live.need = session.others.length;
24027
23632
  if (!live.hub && may && fits && (policy.pin.includes(me) || others.length < MESH_HUBS.maxHubs)) {
@@ -24062,8 +23667,9 @@ var MeshHubs = class {
24062
23667
  this.noteHubs(live, session, now);
24063
23668
  }
24064
23669
  noteHubs(live, session, now) {
24065
- 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);
24066
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);
24067
23673
  }
24068
23674
  /** My entry in the beacon (or none, stepping down), merged with the other fresh hubs I do not drop. */
24069
23675
  async publish(groupId, session, live, now, listed) {
@@ -24148,7 +23754,7 @@ var MeshHubs = class {
24148
23754
  live.hubWaits.delete(key);
24149
23755
  }
24150
23756
  }
24151
- const fresh = meshHubs(live.beacon, me, session.hubPolicy, (key) => rosterHas(session.roster, key), now);
23757
+ const fresh = this.listed(live, session, now);
24152
23758
  const candidates = [...fresh, ...[...hubs].filter((key) => !fresh.some((h) => h.key === key)).map((key) => ({
24153
23759
  key,
24154
23760
  ts: 0,
@@ -24264,7 +23870,7 @@ var MeshHubs = class {
24264
23870
  if (!rosterHas(session.roster, from) || !Array.isArray(frame.k) || frame.k.length > session.roster.length) return false;
24265
23871
  const live = this.get(groupId, now);
24266
23872
  live.reach.set(from, new Set(frame.k.filter((key) => typeof key === "string" && rosterHas(session.roster, key))));
24267
- 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);
24268
23874
  if (!Array.isArray(frame.l) || frame.l.length > session.roster.length) return false;
24269
23875
  const legacy = new Set(group.legacy ?? []);
24270
23876
  let changed = false;
@@ -24744,6 +24350,22 @@ var ENTRY_RELEASE_MS = 2e3;
24744
24350
  var REMOVED_LINGER_MS = 15e3;
24745
24351
  /** How long the tombstone of a group I left waits for the admin to hear it. */
24746
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
+ }
24747
24369
  /** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
24748
24370
  var FAREWELL_ANSWERS = 3;
24749
24371
  /** Commits sent to a removed member that was behind when it left, at most. */
@@ -24805,8 +24427,10 @@ var Groups = class {
24805
24427
  timings;
24806
24428
  stored = /* @__PURE__ */ new Map();
24807
24429
  sessions = /* @__PURE__ */ new Map();
24808
- /** 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. */
24809
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();
24810
24434
  lastRoster = /* @__PURE__ */ new Map();
24811
24435
  /** Each group's status and newest commit as its history last said them: a line per change of either, never one again. */
24812
24436
  lastTold = /* @__PURE__ */ new Map();
@@ -24849,6 +24473,14 @@ var Groups = class {
24849
24473
  */
24850
24474
  relayAsked = /* @__PURE__ */ new Map();
24851
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();
24852
24484
  /** Per mesh group: member key → since when its edge to me is down, and when someone last told me it is here. */
24853
24485
  downSince = /* @__PURE__ */ new Map();
24854
24486
  hereHeard = /* @__PURE__ */ new Map();
@@ -24917,12 +24549,14 @@ var Groups = class {
24917
24549
  this.communities.files = this.files;
24918
24550
  }
24919
24551
  }
24920
- 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) {
24921
24554
  const all = await this.store.getGroups();
24922
24555
  await this.communities.load(all.filter((g) => g.community || g.joining));
24923
24556
  for (const group of all.filter((g) => !g.community && !g.joining)) {
24924
24557
  this.stored.set(group.id, group);
24925
24558
  if (group.state) this.attach(group.state);
24559
+ if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
24926
24560
  const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
24927
24561
  const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
24928
24562
  if (last) this.lastMessageAt.set(group.id, last.timestamp);
@@ -24934,6 +24568,7 @@ var Groups = class {
24934
24568
  }
24935
24569
  for (const id of this.sessions.keys()) this.reconcileEdges(id);
24936
24570
  await this.files?.load(all.map((g) => g.id));
24571
+ if (!asItIs) await this.files?.sweep(all.map((g) => g.id));
24937
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);
24938
24573
  }
24939
24574
  /** A group's pin now (WISP 400 § Pinned message), the latest one taken, unpinned or not. */
@@ -24989,7 +24624,7 @@ var Groups = class {
24989
24624
  linkId: invitation.linkId,
24990
24625
  contact: this.host.contactName(invitation.linkId) ?? "",
24991
24626
  admin: invitation.admin,
24992
- members: invitation.n,
24627
+ members: Math.min(Math.max(invitation.n, 0), GROUP_MEMBER_CAP.max),
24993
24628
  accepted: !!invitation.seedB64,
24994
24629
  ...invitation.entry ? {
24995
24630
  viaLink: true,
@@ -25130,8 +24765,21 @@ var Groups = class {
25130
24765
  let set = this.invited.get(groupId);
25131
24766
  if (!set) this.invited.set(groupId, set = /* @__PURE__ */ new Set());
25132
24767
  set.add(linkId);
24768
+ group.invited = {
24769
+ ...group.invited,
24770
+ [linkId]: Date.now()
24771
+ };
24772
+ await this.store.putGroup(group);
25133
24773
  this.host.emit();
25134
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
+ }
25135
24783
  async accept(groupId) {
25136
24784
  const group = this.stored.get(groupId);
25137
24785
  if (!group?.invitation) throw new Error("No invitation to accept");
@@ -25148,6 +24796,10 @@ var Groups = class {
25148
24796
  g: groupId,
25149
24797
  key: identityFromSeedB64(seedB64).pubKeyZ32
25150
24798
  });
24799
+ this.acceptSaid.set(groupId, {
24800
+ at: this.now(),
24801
+ n: 0
24802
+ });
25151
24803
  this.host.emit();
25152
24804
  }
25153
24805
  async decline(groupId) {
@@ -25160,7 +24812,12 @@ var Groups = class {
25160
24812
  g: groupId
25161
24813
  });
25162
24814
  } catch {}
25163
- 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();
25164
24821
  }
25165
24822
  /**
25166
24823
  * Sends a text; `messageId` is the id it is kept under here (what history, replies and reactions name). `forwarded`:
@@ -25178,6 +24835,23 @@ var Groups = class {
25178
24835
  };
25179
24836
  }
25180
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
+ /**
25181
24855
  * Announces a file or voice message of mine (WISP 503): `text` is its caption, or the line older apps show
25182
24856
  * (`groupFileFallback`), `file` its description with the digest of the bytes kept under `fileId`, which this device
25183
24857
  * then holds and serves. `messageId` is the announcement's.
@@ -25256,8 +24930,8 @@ var Groups = class {
25256
24930
  });
25257
24931
  }
25258
24932
  /**
25259
- * Leaves, and the group is gone from this device at once: its row, its history and every edge
25260
- * 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
25261
24935
  * week passes), so a leave said while the admin was away still reaches it. An admin with other
25262
24936
  * members hands the role to one who is online first; alone, the group simply goes.
25263
24937
  */
@@ -25286,6 +24960,7 @@ var Groups = class {
25286
24960
  ...bye ? { bye } : {}
25287
24961
  };
25288
24962
  delete group.entry;
24963
+ delete group.invited;
25289
24964
  this.invited.delete(groupId);
25290
24965
  this.pendingEntries.delete(groupId);
25291
24966
  this.welcomed.delete(groupId);
@@ -25295,9 +24970,18 @@ var Groups = class {
25295
24970
  this.lastMentionAt.delete(groupId);
25296
24971
  await this.store.deleteGroup(groupId);
25297
24972
  await this.store.putGroup(group);
24973
+ await this.files?.drop(groupId);
25298
24974
  this.host.historyGone?.(groupId);
25299
24975
  for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
25300
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
+ }
25301
24985
  await session.leave();
25302
24986
  if (bye) for (const key of hubs) {
25303
24987
  const edge = this.host.edges(groupId).get(key);
@@ -25470,17 +25154,17 @@ var Groups = class {
25470
25154
  await this.session(groupId).rename(name);
25471
25155
  }
25472
25156
  async forget(groupId) {
25473
- if (this.isCommunity(groupId)) {
25474
- await this.communities.forget(groupId);
25475
- await this.files?.drop(groupId);
25476
- return;
25477
- }
25478
- 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 {
25479
25160
  await this.leave(groupId);
25480
- } catch {}
25161
+ } catch (e) {
25162
+ if (session.isAdmin && session.others.length) throw e;
25163
+ }
25481
25164
  this.sessions.delete(groupId);
25482
25165
  this.stored.delete(groupId);
25483
25166
  this.invited.delete(groupId);
25167
+ this.acceptSaid.delete(groupId);
25484
25168
  this.lastRoster.delete(groupId);
25485
25169
  this.lastTold.delete(groupId);
25486
25170
  this.pendingEntries.delete(groupId);
@@ -25494,6 +25178,8 @@ var Groups = class {
25494
25178
  this.hubs.forget(groupId);
25495
25179
  this.removedAt.delete(groupId);
25496
25180
  this.lastGossip.delete(groupId);
25181
+ this.gapsAsked.delete(groupId);
25182
+ this.gapsLooked.delete(groupId);
25497
25183
  this.typings.forget(groupId);
25498
25184
  this.downSince.delete(groupId);
25499
25185
  this.hereHeard.delete(groupId);
@@ -25557,6 +25243,8 @@ var Groups = class {
25557
25243
  const existing = this.stored.get(link.g);
25558
25244
  if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
25559
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.");
25560
25248
  if (existing) await this.forget(link.g);
25561
25249
  const seedB64 = dialedKey(link);
25562
25250
  const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
@@ -25596,6 +25284,10 @@ var Groups = class {
25596
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(() => {});
25597
25285
  continue;
25598
25286
  }
25287
+ if (group.invitation?.seedB64 && !group.state) {
25288
+ this.acceptAgain(group, now);
25289
+ continue;
25290
+ }
25599
25291
  const session = this.sessions.get(group.id);
25600
25292
  if (!group.entry || !session) continue;
25601
25293
  if (!session.isAdmin) {
@@ -25642,6 +25334,7 @@ var Groups = class {
25642
25334
  }
25643
25335
  for (const [groupId, session] of this.sessions) {
25644
25336
  const group = this.stored.get(groupId);
25337
+ if (session.status !== "active" && !this.removedAt.has(groupId) && this.hubs.isHub(groupId)) this.hubs.forget(groupId);
25645
25338
  if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
25646
25339
  await this.hubs.tick(groupId, session, group, now).catch(() => false);
25647
25340
  if (this.hubReconcile.has(groupId)) continue;
@@ -25653,6 +25346,39 @@ var Groups = class {
25653
25346
  }
25654
25347
  }
25655
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
+ }
25656
25382
  /** An entry session came up with groups on both sides: the admin's side invites over it. */
25657
25383
  entryReady(groupId, linkId, peer) {
25658
25384
  if (this.isCommunity(groupId)) {
@@ -25823,8 +25549,7 @@ var Groups = class {
25823
25549
  name: groupName(frame.name) ?? "Group",
25824
25550
  admin: frame.admin,
25825
25551
  pieces: [],
25826
- e: Number.isSafeInteger(frame.e) ? frame.e : 0,
25827
- n: Number.isSafeInteger(frame.n) ? frame.n : 1
25552
+ ...invitationClaims(frame)
25828
25553
  };
25829
25554
  await this.store.putGroup(existing);
25830
25555
  traceJoin(g, "invite.received");
@@ -25836,13 +25561,29 @@ var Groups = class {
25836
25561
  this.host.emit();
25837
25562
  return;
25838
25563
  }
25839
- if (existing?.state && existing.state.status === "active") return;
25840
- 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;
25841
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
+ }
25842
25582
  const known = {
25843
25583
  ...existing?.formerNames,
25844
25584
  ...existing?.state?.nicks
25845
25585
  };
25586
+ const previous = existing?.state ? existing : existing?.previous;
25846
25587
  const invitation = {
25847
25588
  id: g,
25848
25589
  createdAt: Date.now(),
@@ -25850,17 +25591,18 @@ var Groups = class {
25850
25591
  name: groupName(frame.name) ?? "Group",
25851
25592
  admin: frame.admin,
25852
25593
  linkId,
25853
- e: Number.isSafeInteger(frame.e) ? frame.e : 0,
25854
- n: Number.isSafeInteger(frame.n) ? frame.n : 1,
25594
+ ...invitationClaims(frame),
25855
25595
  pieces: []
25856
25596
  },
25857
- ...Object.keys(known).length ? { formerNames: known } : {}
25597
+ ...Object.keys(known).length ? { formerNames: known } : {},
25598
+ ...previous ? { previous } : {}
25858
25599
  };
25859
25600
  if (existing) {
25860
25601
  this.sessions.delete(g);
25861
25602
  for (const edge of this.host.edges(g).values()) await this.host.closeEdge(edge);
25862
25603
  }
25863
25604
  this.stored.set(g, invitation);
25605
+ this.acceptSaid.delete(g);
25864
25606
  await this.store.putGroup(invitation);
25865
25607
  this.host.emit();
25866
25608
  return;
@@ -25869,6 +25611,7 @@ var Groups = class {
25869
25611
  const session = this.sessions.get(g);
25870
25612
  if (!session?.isAdmin || !this.invited.get(g)?.has(linkId) || typeof frame.key !== "string" || !MEMBER_KEY.test(frame.key)) return;
25871
25613
  const group = this.stored.get(g);
25614
+ if (frame.key === session.myKey || rosterHas(session.roster, frame.key)) return;
25872
25615
  const entryPeer = this.hostEntry(g, linkId);
25873
25616
  if ([...this.host.entries(g).values()].includes(linkId) && entryPeer !== frame.key) return;
25874
25617
  if (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId)) return;
@@ -25880,8 +25623,8 @@ var Groups = class {
25880
25623
  ...group.contacts,
25881
25624
  [frame.key]: linkId
25882
25625
  };
25626
+ this.answered(group, linkId);
25883
25627
  await this.store.putGroup(group);
25884
- this.invited.get(g)?.delete(linkId);
25885
25628
  for (const piece of welcome) this.host.sendOnLink(linkId, piece);
25886
25629
  if (entryPeer) traceJoin(g, "welcome.sent");
25887
25630
  if (entryPeer) {
@@ -25905,7 +25648,10 @@ var Groups = class {
25905
25648
  this.pendingEntries.get(g)?.delete(entryPeer);
25906
25649
  await this.host.closeEdge(linkId);
25907
25650
  }
25908
- 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();
25909
25655
  return;
25910
25656
  }
25911
25657
  case "group-chain": {
@@ -25939,6 +25685,7 @@ var Groups = class {
25939
25685
  contacts: viaLink ? {} : { [group.invitation.admin]: linkId }
25940
25686
  };
25941
25687
  this.stored.set(g, member);
25688
+ this.acceptSaid.delete(g);
25942
25689
  this.attach(joined.state);
25943
25690
  if (viaLink) this.lingering.set(g, {
25944
25691
  linkId,
@@ -26034,6 +25781,7 @@ var Groups = class {
26034
25781
  return;
26035
25782
  }
26036
25783
  if (!session) return;
25784
+ if (t === "group-bye" && session.status === "active" && frame.k === peerKey && this.hubs.departs(groupId, peerKey)) this.host.emit();
26037
25785
  if (t === "group-react" || t === "group-reacted") {
26038
25786
  await this.reaction(groupId, session, peerKey, frame);
26039
25787
  return;
@@ -26124,6 +25872,9 @@ var Groups = class {
26124
25872
  } catch {
26125
25873
  return;
26126
25874
  }
25875
+ if (peerKey === session.admin) try {
25876
+ for (const bye of session.byesHeld()) this.host.sendOnLink(linkId, bye);
25877
+ } catch {}
26127
25878
  const kept = this.lingering.get(groupId);
26128
25879
  if (kept?.admin === peerKey) {
26129
25880
  if (this.signals.keptEntry(groupId) === kept.linkId) setTimeout(() => {
@@ -26239,13 +25990,123 @@ var Groups = class {
26239
25990
  });
26240
25991
  out.push(key);
26241
25992
  }
25993
+ out.push(...this.gapsOf(groupId, session, via, session.lacking()));
26242
25994
  if (out.length) this.lastGossip.set(groupId, now);
26243
25995
  return out;
26244
25996
  }
26245
25997
  /**
26246
- * While some member is unreachable, one connected member (in turn) is asked, once a `MESH_GOSSIP_MS`, for what it
26247
- * has from the unreachable ones: a message sent while the author could reach it and not me gets here within that,
26248
- * 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.
26249
26110
  */
26250
26111
  meshTick(now) {
26251
26112
  this.tickNow = now;
@@ -26261,10 +26122,13 @@ var Groups = class {
26261
26122
  }
26262
26123
  const away = this.unreachable(groupId, session);
26263
26124
  this.noteDown(groupId, session, away, now);
26125
+ const connected = session.others.filter((key) => !away.includes(key));
26126
+ this.gapsAgain(groupId, session, now, connected);
26264
26127
  if (now - (this.lastGossip.get(groupId) ?? -Infinity) < 6e4) continue;
26265
26128
  const edges = this.host.edges(groupId);
26266
- const connected = session.others.filter((key) => !away.includes(key));
26267
- if (!away.length || !connected.length) continue;
26129
+ if (!connected.length) continue;
26130
+ const lacking = session.lacking();
26131
+ if (!away.length && !lacking.size) continue;
26268
26132
  this.lastGossip.set(groupId, now);
26269
26133
  const via = connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
26270
26134
  const asked = this.relayAsked.get(groupId) ?? /* @__PURE__ */ new Map();
@@ -26273,8 +26137,10 @@ var Groups = class {
26273
26137
  via,
26274
26138
  at: now
26275
26139
  });
26140
+ const ask = [...away, ...this.gapsOf(groupId, session, via, lacking)];
26141
+ if (!ask.length) continue;
26276
26142
  try {
26277
- this.host.sendOnLink(edges.get(via), session.syncFrame(away));
26143
+ this.host.sendOnLink(edges.get(via), session.syncFrame(ask));
26278
26144
  } catch {}
26279
26145
  }
26280
26146
  }
@@ -26419,7 +26285,7 @@ var Groups = class {
26419
26285
  }
26420
26286
  /** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
26421
26287
  async metaChanged(groupId, session, by, change, at) {
26422
- 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)}`;
26423
26289
  const when = at === void 0 ? Date.now() : receivedTimestamp(at);
26424
26290
  for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
26425
26291
  this.host.emit();
@@ -27014,7 +26880,8 @@ var GroupEdits = class {
27014
26880
  }
27015
26881
  /** An edge of a private group opened: the member at its other end hears my latest edits again, in case it missed them. */
27016
26882
  async resend(groupId, to) {
27017
- 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();
27018
26885
  for (const m of mine) await this.host.send(groupId, {
27019
26886
  id: m.id,
27020
26887
  e: m.edit.seq,
@@ -27154,7 +27021,8 @@ var CommunityPay = class {
27154
27021
  },
27155
27022
  memo: frame.memo,
27156
27023
  endpoints: frame.e,
27157
- ask: frame.a
27024
+ ask: frame.a,
27025
+ network: frame.n
27158
27026
  });
27159
27027
  else if (frame.t === "pay-ask") await this.host.onPaymentAsk(linkId, {
27160
27028
  id: frame.id,
@@ -27164,7 +27032,8 @@ var CommunityPay = class {
27164
27032
  asset: frame.u
27165
27033
  },
27166
27034
  method: frame.m,
27167
- memo: frame.memo
27035
+ memo: frame.memo,
27036
+ network: frame.n
27168
27037
  });
27169
27038
  else if (frame.t === "pay") await this.host.onPayment(linkId, {
27170
27039
  id: frame.id,
@@ -27208,7 +27077,8 @@ var CommunityPay = class {
27208
27077
  asset: frame.u
27209
27078
  },
27210
27079
  memo: frame.memo,
27211
- endpoints: frame.e
27080
+ endpoints: frame.e,
27081
+ network: frame.n
27212
27082
  });
27213
27083
  else if (frame.t === "pay-res" && frame.ok) await this.host.onPaymentResult(linkId, {
27214
27084
  id: frame.id,
@@ -27330,7 +27200,8 @@ var PairLink = class {
27330
27200
  u: r.amount.asset,
27331
27201
  memo: r.memo,
27332
27202
  e: r.endpoints,
27333
- a: r.ask
27203
+ a: r.ask,
27204
+ n: r.network
27334
27205
  });
27335
27206
  }
27336
27207
  async sendPaymentAsk(a) {
@@ -27342,7 +27213,8 @@ var PairLink = class {
27342
27213
  v: a.amount.value,
27343
27214
  u: a.amount.asset,
27344
27215
  m: a.method,
27345
- memo: a.memo
27216
+ memo: a.memo,
27217
+ n: a.network
27346
27218
  });
27347
27219
  }
27348
27220
  async sendPayment(p) {
@@ -27423,7 +27295,8 @@ var GroupLink = class {
27423
27295
  v: r.amount.value,
27424
27296
  u: r.amount.asset,
27425
27297
  memo: r.memo,
27426
- e: r.endpoints
27298
+ e: r.endpoints,
27299
+ n: r.network
27427
27300
  }));
27428
27301
  await this.host.sendApp(this.groupId, {
27429
27302
  ...frame,
@@ -28082,16 +27955,17 @@ var HoldEngine = class {
28082
27955
  }, held);
28083
27956
  } else if (header.kind === "file") {
28084
27957
  const meta = header.meta;
28085
- const voice = parseVoiceMeta(meta.voice, meta.mime);
28086
- const video = parseVideoMeta(meta.video, meta.mime);
28087
- 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);
28088
27962
  const reply = readReply(meta.r, pairedReplyAuthor);
28089
27963
  const forwarded = readForwarded(meta.fw);
28090
27964
  const refused = await this.host.receiveFile(linkId, {
28091
27965
  wireId: header.id,
28092
- name: meta.name,
27966
+ name,
28093
27967
  size: meta.size,
28094
- mime: meta.mime,
27968
+ mime,
28095
27969
  timestamp: header.ts,
28096
27970
  ...voice && { voice },
28097
27971
  ...video && { video },
@@ -29491,7 +29365,10 @@ var CashuWallet = class CashuWallet {
29491
29365
  }
29492
29366
  /** How long `stop` waits for a swap in a per-mint lock. */
29493
29367
  static STOP_WAIT_MS = 3e4;
29494
- /** 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
+ */
29495
29372
  async view(network) {
29496
29373
  const proofs = await this.allProofs();
29497
29374
  const mints = this.getMints(network).map((url) => ({
@@ -29500,8 +29377,7 @@ var CashuWallet = class CashuWallet {
29500
29377
  balance: total(proofs.filter((p) => p.mint === url && !p.reserved)),
29501
29378
  info: this.infos.get(url) ?? null
29502
29379
  }));
29503
- const all = await wrap((await store(STORES$1.walletTx, "readonly")).getAll());
29504
- all.sort((a, b) => b.timestamp - a.timestamp);
29380
+ const history = await newestWalletTx((mint) => !network || mintNetwork(mint) === network, HISTORY_SHOWN);
29505
29381
  const here = new Set(mints.map((m) => m.url));
29506
29382
  const swaps = (await wrap((await store(STORES$1.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
29507
29383
  const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
@@ -29513,8 +29389,8 @@ var CashuWallet = class CashuWallet {
29513
29389
  openSwaps: swaps.length,
29514
29390
  swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
29515
29391
  unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
29516
- history: all.slice(0, HISTORY_SHOWN),
29517
- feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
29392
+ history,
29393
+ feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0)
29518
29394
  };
29519
29395
  }
29520
29396
  /**
@@ -30078,7 +29954,7 @@ var CashuWallet = class CashuWallet {
30078
29954
  unit: metadata.unit,
30079
29955
  mint,
30080
29956
  memo: metadata.memo || void 0,
30081
- accepted: this.getKnownMints().includes(mint) || isTestMint(mint)
29957
+ accepted: metadata.unit === UNIT$1 && (this.getKnownMints().includes(mint) || isTestMint(mint))
30082
29958
  };
30083
29959
  }
30084
29960
  if (/^creq[AB]/i.test(value)) {
@@ -30101,14 +29977,16 @@ var CashuWallet = class CashuWallet {
30101
29977
  async receiveToken(token, kind = "ecash-in", note, { addTestMint = true, payment } = {}) {
30102
29978
  let mint;
30103
29979
  let faceValue;
29980
+ let unit;
30104
29981
  try {
30105
29982
  const metadata = getTokenMetadata(token);
30106
- if (metadata.unit !== UNIT$1) throw new Error("unit");
29983
+ unit = metadata.unit;
30107
29984
  mint = metadata.mint.replace(/\/+$/, "");
30108
29985
  faceValue = Number(metadata.amount);
30109
29986
  } catch {
30110
29987
  throw new Error("That is not a valid ecash token");
30111
29988
  }
29989
+ if (unit !== UNIT$1) throw engineError("ecashOtherUnit", { unit });
30112
29990
  if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
30113
29991
  if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
30114
29992
  return this.locked(mint, async () => {
@@ -30157,9 +30035,10 @@ var CashuWallet = class CashuWallet {
30157
30035
  }
30158
30036
  /**
30159
30037
  * Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
30160
- * no mint. Otherwise it is a swap: written down, with its inputs reserved, before the mint is asked. The caller
30161
- * stores the result in one transaction: `inputs` out, `keep` in, `send` where it goes, and `swap` deleted.
30162
- * 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.
30163
30042
  */
30164
30043
  async split(mint, amount, proofs) {
30165
30044
  const wallet = await this.wallet(mint);
@@ -30172,7 +30051,11 @@ var CashuWallet = class CashuWallet {
30172
30051
  includeFees: true,
30173
30052
  exactMatch: true
30174
30053
  });
30175
- 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 {
30176
30059
  inputs: among(send),
30177
30060
  keep: [],
30178
30061
  send
@@ -30750,7 +30633,7 @@ var CashuWallet = class CashuWallet {
30750
30633
  let wallet = this.wallets.get(mint);
30751
30634
  if (!wallet) {
30752
30635
  wallet = (async () => {
30753
- const instance = new Wallet$1(mint, { unit: UNIT$1 });
30636
+ const instance = new Wallet(mint, { unit: UNIT$1 });
30754
30637
  await within(MINT_TIMEOUT_MS, instance.loadMint(), mint);
30755
30638
  return instance;
30756
30639
  })();
@@ -31543,10 +31426,13 @@ var PaymentDesk = class {
31543
31426
  });
31544
31427
  return { askId };
31545
31428
  }
31546
- /** The ask a request answers, only if this side made it: same chat, way of paying and amount, still fresh. */
31547
- 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) {
31548
31434
  const ask = request.ask ? this.asks.get(request.ask) : void 0;
31549
- 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;
31550
31436
  this.asks.delete(request.ask);
31551
31437
  return request.ask;
31552
31438
  }
@@ -31669,7 +31555,7 @@ var PaymentDesk = class {
31669
31555
  await this.save({
31670
31556
  target,
31671
31557
  federations,
31672
- 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,
31673
31559
  id: this.keyFor(linkId, request.id),
31674
31560
  linkId,
31675
31561
  kind: "request",
@@ -32095,7 +31981,7 @@ var PaymentDesk = class {
32095
31981
  memo: request.memo,
32096
31982
  state: "pending",
32097
31983
  createdAt: request.timestamp,
32098
- ask: this.answering(linkId, request, "usdt", amount),
31984
+ ask: this.answering(linkId, request, "usdt", amount, network),
32099
31985
  network
32100
31986
  });
32101
31987
  await this.host.storeMessage({
@@ -32336,6 +32222,7 @@ var PaymentDesk = class {
32336
32222
  }
32337
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;
32338
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;
32339
32226
  await this.save({
32340
32227
  id: payment.id,
32341
32228
  linkId,
@@ -33477,7 +33364,7 @@ function pushRelayProblem(url) {
33477
33364
  "localhost",
33478
33365
  "[::1]"
33479
33366
  ].includes(parsed.hostname)) return null;
33480
- return "Use an https:// relay address";
33367
+ return `Use an https:// relay address: ${url}`;
33481
33368
  }
33482
33369
  //#endregion
33483
33370
  //#region ../browser/src/engine/walletSetup.ts
@@ -33598,6 +33485,8 @@ var RELAY_RETRY_MS = 3e4;
33598
33485
  var NATIVE_SLOTS = 8;
33599
33486
  /** How often a group link that found no free native slot tries again. */
33600
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;
33601
33490
  /** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
33602
33491
  var CLEAR_WITHDRAW_MS = 6e3;
33603
33492
  /**
@@ -33628,6 +33517,16 @@ var CARD_RESTORE_MOST = 50;
33628
33517
  var CARD_RESTORE_MS = 6048e5;
33629
33518
  /** A reaction said on the live session and not confirmed is said again after this long. */
33630
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
+ }
33631
33530
  function emptyLinkFiles() {
33632
33531
  return {
33633
33532
  receivedBytes: 0,
@@ -33833,6 +33732,11 @@ var GhostlyNode = class GhostlyNode {
33833
33732
  return Promise.all(this.nativeQueues.values()).then(() => {});
33834
33733
  }
33835
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
+ });
33836
33740
  /** `start` has put the kept transfers back (EngineState.transfersRestored). */
33837
33741
  transfersRestored = false;
33838
33742
  feedbackStartedAt = Date.now();
@@ -34421,10 +34325,10 @@ var GhostlyNode = class GhostlyNode {
34421
34325
  } : void 0;
34422
34326
  },
34423
34327
  keys: (linkId) => {
34424
- const stored = this.links.get(linkId)?.stored;
34328
+ const live = this.links.get(linkId);
34425
34329
  return {
34426
- mine: stored?.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0,
34427
- theirs: stored?.pairedPeerKey
34330
+ mine: live && participationKeyOf(live),
34331
+ theirs: live?.stored.pairedPeerKey
34428
34332
  };
34429
34333
  },
34430
34334
  linkIds: () => [...this.links.keys()],
@@ -34463,9 +34367,9 @@ var GhostlyNode = class GhostlyNode {
34463
34367
  emit: () => this.emitState(),
34464
34368
  ownSubjects: () => this.identities.views().filter((p) => p.provider === "nostr").map((p) => p.subject),
34465
34369
  contactSubjects: (linkId) => {
34466
- const stored = this.links.get(linkId)?.stored;
34370
+ const live = this.links.get(linkId), stored = live?.stored;
34467
34371
  if (!stored?.profile || !stored.identities) return [];
34468
- const mine = stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0;
34372
+ const mine = participationKeyOf(live);
34469
34373
  const t = Math.floor(Date.now() / 1e3);
34470
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))];
34471
34375
  },
@@ -34527,7 +34431,7 @@ var GhostlyNode = class GhostlyNode {
34527
34431
  for (const live of this.links.values()) {
34528
34432
  const stored = live.stored;
34529
34433
  if (!stored.profile || !stored.identities || stored.group) continue;
34530
- const mine = stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0;
34434
+ const mine = participationKeyOf(live);
34531
34435
  for (const r of stored.identities.received) if (receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified") out.push({
34532
34436
  provider: r.binding.provider,
34533
34437
  subject: r.verified.subject,
@@ -34577,6 +34481,11 @@ var GhostlyNode = class GhostlyNode {
34577
34481
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34578
34482
  changed: async (chat, id, note) => {
34579
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
+ }
34580
34489
  await this.messagesChanged(chat, [id]);
34581
34490
  this.emitState();
34582
34491
  },
@@ -34702,6 +34611,7 @@ var GhostlyNode = class GhostlyNode {
34702
34611
  */
34703
34612
  groupEdits = new GroupEdits({
34704
34613
  messages: (chat) => db.getMessages(chat),
34614
+ history: (chat) => this.edgeOpenHistory(chat),
34705
34615
  message: (chat, id) => db.getMessage(chat, id),
34706
34616
  patch: (chat, id, change) => db.patchMessage(chat, id, change),
34707
34617
  changed: (chat, id) => this.messagesChanged(chat, [id]),
@@ -34911,7 +34821,7 @@ var GhostlyNode = class GhostlyNode {
34911
34821
  const awaiting = await this.readAwaiting();
34912
34822
  for (const network of WALLET_NETWORKS) {
34913
34823
  const view = await this.wallet.view(network);
34914
- everything = view.history;
34824
+ everything = everything.concat(view.history);
34915
34825
  const history = view.history.filter((tx) => !tx.mint || mintNetwork(tx.mint) === network);
34916
34826
  networks[network] = {
34917
34827
  mints: this.withWaits(view.mints, awaiting.mints),
@@ -35071,7 +34981,7 @@ var GhostlyNode = class GhostlyNode {
35071
34981
  }
35072
34982
  this.transfersRestored = true;
35073
34983
  for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
35074
- await this.groups.load();
34984
+ await this.groups.load(this.limitedMode);
35075
34985
  for (const group of this.groups.views()) {
35076
34986
  const note = latestReaction(await db.getMessages(`group:${group.id}`));
35077
34987
  if (note) this.reactionNotes.set(`group:${group.id}`, note);
@@ -35471,12 +35381,17 @@ var GhostlyNode = class GhostlyNode {
35471
35381
  async shutdown(options = {}) {
35472
35382
  const quiet = options.quiet === true || this.gatedOut;
35473
35383
  this.shuttingDown = true;
35384
+ this.stopBegan();
35474
35385
  if (this.limitedTimer) {
35475
35386
  clearTimeout(this.limitedTimer);
35476
35387
  this.limitedTimer = null;
35477
35388
  }
35478
35389
  this.appShelf?.stop();
35479
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
+ }));
35480
35395
  this.directPath.close();
35481
35396
  this.clock.close();
35482
35397
  this.clockOff?.();
@@ -35504,33 +35419,30 @@ var GhostlyNode = class GhostlyNode {
35504
35419
  this.activeTurnTimer = null;
35505
35420
  if (this.restoreTimer) clearTimeout(this.restoreTimer);
35506
35421
  this.restoreTimer = null;
35507
- await links?.stop().catch(() => {});
35508
35422
  this.identities.stop();
35509
35423
  this.did.stop();
35510
35424
  this.nostrSocial.stop();
35511
35425
  this.publicProfiles.stop();
35512
35426
  this.publicActivity.clear();
35513
- for (const network of WALLET_NETWORKS) {
35514
- await this.arkWallets[network].stop();
35515
- await this.barkWallets[network].stop();
35516
- await this.fedimintWallets[network].stop();
35517
- await this.sparkWallets[network].stop();
35518
- await this.usdtWallets[network].stop();
35519
- await this.lightnings[network].stop();
35520
- await this.bitcoins[network].stop();
35521
- }
35522
- await this.wallet.stop();
35523
- await this.nativeQueue;
35524
- await this.hold.stop();
35525
- await Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop()));
35526
35427
  for (const queue of this.editQueues.values()) queue.stop();
35527
35428
  this.groupEdits.stop();
35528
35429
  this.cardEdits.stop();
35529
- await Promise.allSettled([...this.links.values()].map(async (live) => {
35530
- live.carried?.stop();
35531
- await live.link?.stop(!quiet);
35532
- await live.caps?.stop();
35533
- }));
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
+ ]);
35534
35446
  }
35535
35447
  /**
35536
35448
  * "Clear all data" (WISP 04), the peer's part. First what can be taken back from the network, best effort and within
@@ -35551,7 +35463,7 @@ var GhostlyNode = class GhostlyNode {
35551
35463
  appShelf;
35552
35464
  /** The installed apps and stores, refused while the apps feature is off. */
35553
35465
  get appStore() {
35554
- if (!this.appsOn) throw new Error("Apps are unavailable in this release");
35466
+ if (!this.appsOn) throw this.appsOff();
35555
35467
  return this.appShelf ??= new Apps({
35556
35468
  fetch: boundedAppFetch({
35557
35469
  fetcher: this.options.appFetch,
@@ -35707,6 +35619,10 @@ var GhostlyNode = class GhostlyNode {
35707
35619
  getMessages(linkId) {
35708
35620
  return db.getMessages(linkId);
35709
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
+ }
35710
35626
  async messagePage({ linkId, limit = 50, before }) {
35711
35627
  if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("limit: a whole number of messages, at least 1");
35712
35628
  if (before === void 0) return db.getMessagePage(linkId, { limit });
@@ -36471,6 +36387,10 @@ var GhostlyNode = class GhostlyNode {
36471
36387
  this.transport.networkChanged?.();
36472
36388
  this.directPath.reset();
36473
36389
  }
36390
+ if (params.network) for (const failed of this.groupNativeFailed.values()) {
36391
+ failed.at = 0;
36392
+ failed.wait = 0;
36393
+ }
36474
36394
  for (const live of this.links.values()) live.link?.wake({ network: params.network });
36475
36395
  this.hold.wake();
36476
36396
  for (const network of WALLET_NETWORKS) {
@@ -36694,12 +36614,26 @@ var GhostlyNode = class GhostlyNode {
36694
36614
  /**
36695
36615
  * A received reply, or one sent before its original was here, against this chat's own history: the original found
36696
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.
36697
36620
  */
36698
36621
  async resolveReply(message) {
36699
36622
  const reply = message.replyTo;
36700
36623
  if (!reply || reply.messageId) return message;
36701
- const history = await db.getMessages(message.linkId);
36702
- 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
+ }
36703
36637
  if (!original) return message;
36704
36638
  const resolved = {
36705
36639
  ...message,
@@ -36709,7 +36643,7 @@ var GhostlyNode = class GhostlyNode {
36709
36643
  ...reply.button && { button: reply.button }
36710
36644
  }
36711
36645
  };
36712
- const press = buttonPress(resolved, original, history);
36646
+ const press = buttonPress(resolved, original, []) && buttonPress(resolved, original, history ?? await db.getMessages(message.linkId));
36713
36647
  return press ? {
36714
36648
  ...resolved,
36715
36649
  press
@@ -36875,19 +36809,27 @@ var GhostlyNode = class GhostlyNode {
36875
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
36876
36810
  * row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later edit of the
36877
36811
  * author's replaces it, the highest number winning). A contact whose app does not show the kind (it would drop the card
36878
- * and mark the text edited) gets none: the row waits for one that does. Bounded: the newest `CARD_RESTORE_MOST` such
36879
- * messages of the last `CARD_RESTORE_MS`; older ones are given up. The edit is marked `restore`: the bot's own event
36880
- * 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.
36881
36817
  */
36882
36818
  async restoreCards(linkId) {
36883
36819
  const live = this.links.get(linkId), link = live?.link;
36884
36820
  const offers = link?.sessionOffers.peer;
36885
36821
  if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !offers) return;
36886
- 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);
36887
36823
  const since = Date.now() - CARD_RESTORE_MS;
36888
36824
  const changed = [];
36889
- for (const [index, message] of due.entries()) {
36890
- 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;
36891
36833
  if (!stale && message.card && !offers.includes(GhostlyNode.cardCapability(message.card))) continue;
36892
36834
  const at = Date.now();
36893
36835
  if ((await db.patchMessage(linkId, message.id, (current) => {
@@ -37078,7 +37020,7 @@ var GhostlyNode = class GhostlyNode {
37078
37020
  return composeDetails(message, {
37079
37021
  link: live && stored && {
37080
37022
  stored,
37081
- myKey: stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : live.myPubKeyZ32,
37023
+ myKey: participationKeyOf(live) ?? live.myPubKeyZ32,
37082
37024
  verified: !!stored.pairedPeerKey && (stored.peerTrust ? stored.peerTrust.verifiedKey === stored.pairedPeerKey : true),
37083
37025
  transportNow: live.link?.isDataLinkOpen ? live.pairing?.transport : void 0,
37084
37026
  relayedNow: !!live.link?.relayedPath,
@@ -37889,11 +37831,23 @@ var GhostlyNode = class GhostlyNode {
37889
37831
  /**
37890
37832
  * `chosen`: a choice from the chat's Connection menu, a row in its timeline even when it names the transport already
37891
37833
  * set. Left out (the RPC), only a change of transport is: the Fallback switch sends the same preference again, and
37892
- * 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.
37893
37836
  */
37894
37837
  async setTransportPreference({ linkId, preferred, fallback }, chosen) {
37895
37838
  const live = this.links.get(linkId);
37896
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
+ }
37897
37851
  chosen ??= live.stored.preferredTransport !== preferred;
37898
37852
  const patch = {
37899
37853
  preferredTransport: preferred,
@@ -38140,6 +38094,18 @@ var GhostlyNode = class GhostlyNode {
38140
38094
  f: reply.member
38141
38095
  }, readForwarded(hops));
38142
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
+ }
38143
38109
  groupMessages({ groupId }) {
38144
38110
  return this.groups.messages(groupId);
38145
38111
  }
@@ -38427,19 +38393,47 @@ var GhostlyNode = class GhostlyNode {
38427
38393
  } catch {}
38428
38394
  return { error: null };
38429
38395
  }
38430
- /** 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
+ */
38431
38415
  async resendGroupReactions(groupId, linkId) {
38432
38416
  if (this.outsideEdge(linkId)) return;
38433
- const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
38434
- for (const reaction of mine) try {
38435
- this.links.get(linkId)?.link?.sendGroupFrame({
38436
- t: GROUP_REACTION_FRAME,
38437
- g: groupId,
38438
- ...reaction,
38439
- ...this.groups.signReaction(groupId, reaction)
38440
- });
38441
- } catch {
38442
- 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));
38443
38437
  }
38444
38438
  }
38445
38439
  /**
@@ -38506,11 +38500,15 @@ var GhostlyNode = class GhostlyNode {
38506
38500
  }
38507
38501
  /** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
38508
38502
  get appsOn() {
38509
- 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");
38510
38508
  }
38511
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). */
38512
38510
  appChat(linkId, ref) {
38513
- if (!this.appsOn) throw new Error("Apps are unavailable in this release");
38511
+ if (!this.appsOn) throw this.appsOff();
38514
38512
  const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
38515
38513
  if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
38516
38514
  if (!isAppRef(ref)) throw new Error("Not an app reference");
@@ -40595,6 +40593,7 @@ var GhostlyNode = class GhostlyNode {
40595
40593
  this.groupWakeReceived.delete(linkId);
40596
40594
  this.quietEdges.delete(linkId);
40597
40595
  this.groupNativeWaiting.delete(linkId);
40596
+ this.groupNativeFailed.delete(linkId);
40598
40597
  this.groupNativeRetryAt = 0;
40599
40598
  if (live.stored.wakeToken && this.meshEdge(live.stored)) {
40600
40599
  const group = live.stored.group, peer = live.stored.groupPeer;
@@ -40763,6 +40762,7 @@ var GhostlyNode = class GhostlyNode {
40763
40762
  automatic: true
40764
40763
  },
40765
40764
  packetTransports: !this.options.webrtcGroupLinks,
40765
+ packetTransportsSaid: !!stored.transportSeeds && Object.keys(stored.transportSeeds).length > 0,
40766
40766
  resumeFloor: !entry && stored.edgeLive ? stored.edgeLiveSince : void 0,
40767
40767
  pairing: {
40768
40768
  credentials: {
@@ -40823,6 +40823,21 @@ var GhostlyNode = class GhostlyNode {
40823
40823
  };
40824
40824
  db.patchLink(linkId, patch).catch(() => {});
40825
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();
40826
40841
  },
40827
40842
  ...entry ? {} : {
40828
40843
  onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
@@ -40865,12 +40880,16 @@ var GhostlyNode = class GhostlyNode {
40865
40880
  this.emitState();
40866
40881
  },
40867
40882
  onStatus: (status) => {
40868
- live.status = status;
40869
- this.emitState();
40883
+ if (status !== live.status) {
40884
+ live.status = status;
40885
+ this.emitState();
40886
+ }
40870
40887
  },
40871
40888
  onDiscoveryError: (error) => {
40872
- live.discoveryError = error ?? void 0;
40873
- this.emitState();
40889
+ if ((error ?? void 0) !== live.discoveryError) {
40890
+ live.discoveryError = error ?? void 0;
40891
+ this.emitState();
40892
+ }
40874
40893
  }
40875
40894
  }
40876
40895
  });
@@ -40922,6 +40941,7 @@ var GhostlyNode = class GhostlyNode {
40922
40941
  expectedPeerKey: stored.peerParticipationKeyZ32
40923
40942
  } : void 0;
40924
40943
  const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? resumeHere(this.transportLogOf(live), this.transportsHere(), stored.peerTransports ?? live.caps?.peer?.transports) : {};
40944
+ let dhtShown;
40925
40945
  live.link = new GhostLink({
40926
40946
  paymentMethods: stored.paymentMethods,
40927
40947
  paymentNetworks: this.chatNetworks(stored),
@@ -41045,10 +41065,14 @@ var GhostlyNode = class GhostlyNode {
41045
41065
  },
41046
41066
  onGroupFrame: stored.profile ? (frame) => this.groups.handleContactFrame(linkId, frame) : void 0,
41047
41067
  onGroupsSupport: () => this.emitState(),
41048
- onDhtDelivery: () => {
41068
+ onDhtDelivery: (view) => {
41049
41069
  if (stored.profile && !stored.group) this.outboxes.get(linkId)?.flush().catch(() => {}).then(() => this.editQueues.get(linkId)?.flush()).catch(() => {});
41050
41070
  this.observeTransport(linkId);
41051
- this.emitState();
41071
+ const shown = JSON.stringify(view);
41072
+ if (shown !== dhtShown) {
41073
+ dhtShown = shown;
41074
+ this.emitState();
41075
+ }
41052
41076
  },
41053
41077
  onHold: (state) => this.hold.peerSaid(linkId, state),
41054
41078
  onPeerProof: void 0,
@@ -41086,8 +41110,10 @@ var GhostlyNode = class GhostlyNode {
41086
41110
  else this.emitState();
41087
41111
  },
41088
41112
  onDiscoveryError: (error) => {
41089
- live.discoveryError = error ?? void 0;
41090
- this.emitState();
41113
+ if ((error ?? void 0) !== live.discoveryError) {
41114
+ live.discoveryError = error ?? void 0;
41115
+ this.emitState();
41116
+ }
41091
41117
  },
41092
41118
  onTransportDiscovery: async (peerDescriptors, peerTransports, peerFallback) => {
41093
41119
  const patch = {
@@ -41112,23 +41138,27 @@ var GhostlyNode = class GhostlyNode {
41112
41138
  this.emitState();
41113
41139
  },
41114
41140
  onStatus: (status) => {
41141
+ const was = live.status, synced = live.lastSyncAt;
41115
41142
  live.status = status;
41116
41143
  if (status === "online") live.lastSyncAt = Date.now();
41117
- this.emitState();
41144
+ if (status !== was || !synced || stored.group || linkId === this.activeLinkId) this.emitState();
41118
41145
  },
41119
41146
  onPoll: ({ polling, nextInMs }) => {
41147
+ const was = live.poll.polling;
41120
41148
  live.poll = {
41121
41149
  polling,
41122
41150
  nextAt: Date.now() + nextInMs,
41123
41151
  interval: nextInMs || live.poll.interval
41124
41152
  };
41125
- this.emitState();
41153
+ if (linkId === this.activeLinkId || !live.lastSyncAt && polling !== was) this.emitState();
41126
41154
  },
41127
41155
  onMessageReceipt: async (id) => {
41128
- 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);
41129
41159
  await this.outboxFor(linkId).received(id);
41130
- if (stored.profile && !stored.group) await this.editsFor(linkId).receivedOnDht(id).catch(() => {});
41131
- 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(() => {});
41132
41162
  },
41133
41163
  onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
41134
41164
  onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
@@ -41321,7 +41351,7 @@ var GhostlyNode = class GhostlyNode {
41321
41351
  }
41322
41352
  }
41323
41353
  nativeUp?.then(() => {
41324
- 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(() => {});
41325
41355
  });
41326
41356
  }
41327
41357
  /**
@@ -41401,7 +41431,7 @@ var GhostlyNode = class GhostlyNode {
41401
41431
  get nativeFactories() {
41402
41432
  const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
41403
41433
  return {
41404
- ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-DvXgebV7.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41434
+ ...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-DmTrpmba.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
41405
41435
  ...this.options.nativeTransports,
41406
41436
  ...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
41407
41437
  ...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
@@ -41503,13 +41533,23 @@ var GhostlyNode = class GhostlyNode {
41503
41533
  const live = this.links.get(linkId), link = live?.link;
41504
41534
  if (this.shuttingDown || !live || !link || link !== expected) return;
41505
41535
  if (live.stored.group && this.keepsGroupNative(live.stored)) {
41506
- 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);
41507
41537
  else this.groupNativeWaiting.delete(linkId);
41508
- }
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);
41509
41546
  this.emitState();
41510
41547
  });
41511
41548
  }
41512
- /** 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
+ */
41513
41553
  async startNativeEndpoint(linkId, expected, key, factory, inUse) {
41514
41554
  const live = this.links.get(linkId), link = live?.link;
41515
41555
  if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
@@ -41519,7 +41559,7 @@ var GhostlyNode = class GhostlyNode {
41519
41559
  live.transportErrors ??= {};
41520
41560
  try {
41521
41561
  const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
41522
- 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";
41523
41563
  if (owners.length >= NATIVE_SLOTS) {
41524
41564
  const now = Date.now();
41525
41565
  const taking = !group && (this.activeLinkId === linkId || inUse);
@@ -41563,7 +41603,12 @@ var GhostlyNode = class GhostlyNode {
41563
41603
  transportSeeds
41564
41604
  };
41565
41605
  await db.patchLink(linkId, { transportSeeds });
41566
- 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
+ }
41567
41612
  if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
41568
41613
  await endpoint.close();
41569
41614
  return false;
@@ -41574,6 +41619,7 @@ var GhostlyNode = class GhostlyNode {
41574
41619
  this.emitState();
41575
41620
  } catch (error) {
41576
41621
  live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
41622
+ return "failed";
41577
41623
  }
41578
41624
  return false;
41579
41625
  }
@@ -41644,8 +41690,23 @@ var GhostlyNode = class GhostlyNode {
41644
41690
  /** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
41645
41691
  groupNativeWaiting = /* @__PURE__ */ new Set();
41646
41692
  groupNativeRetryAt = 0;
41647
- /** 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
+ */
41648
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
+ }
41649
41710
  if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
41650
41711
  this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
41651
41712
  for (const linkId of [...this.groupNativeWaiting]) {
@@ -41846,7 +41907,7 @@ var GhostlyNode = class GhostlyNode {
41846
41907
  },
41847
41908
  ...this.reactionNotes.has(stored.id) && { lastReaction: this.reactionNotes.get(stored.id) },
41848
41909
  ...stored.pin && { pin: pinView(stored.pin) },
41849
- participationKey: stored.participationSeed ? identityFromSeedB64(stored.participationSeed).pubKeyZ32 : void 0,
41910
+ participationKey: participationKeyOf(live),
41850
41911
  peerParticipationKey: stored.pairedPeerKey,
41851
41912
  publicProfiles: void 0,
41852
41913
  profileChoice: void 0,
@@ -41963,6 +42024,9 @@ var EngineServer = class {
41963
42024
  * call (WISP 06 § States and events). A client that goes away (a tab closed mid-call) is taken out with it.
41964
42025
  */
41965
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();
41966
42030
  constructor(options = {}) {
41967
42031
  this.node = new GhostlyNode({
41968
42032
  onAttention: (event) => this.broadcast({
@@ -41973,8 +42037,14 @@ var EngineServer = class {
41973
42037
  kind: "state",
41974
42038
  state
41975
42039
  }),
41976
- onMessages: (linkId, messages) => void this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {}),
41977
- 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
+ },
41978
42048
  onCallSignal: (linkId, signal) => {
41979
42049
  if (signalKind(signal) === "o") this.offers.set(linkId, signal);
41980
42050
  else this.offers.delete(linkId);
@@ -41998,9 +42068,21 @@ var EngineServer = class {
41998
42068
  this.ready = this.node.start();
41999
42069
  this.ready.catch(() => {});
42000
42070
  }
42001
- 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) {
42002
42080
  this.clients.add(client);
42003
- 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);
42004
42086
  this.ready.then(async () => {
42005
42087
  if (!this.clients.has(client)) return;
42006
42088
  client.post({
@@ -42013,15 +42095,18 @@ var EngineServer = class {
42013
42095
  signal
42014
42096
  });
42015
42097
  else this.offers.delete(linkId);
42016
- for (const link of this.node.getState().links) await this.inOrder(link.id, async () => {
42017
- if (!this.clients.has(client)) return;
42018
- client.post({
42019
- kind: "messages",
42020
- linkId: link.id,
42021
- 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);
42022
42108
  });
42023
- this.histories.get(client)?.add(link.id);
42024
- });
42109
+ }
42025
42110
  }, (error) => {
42026
42111
  this.post(client, {
42027
42112
  kind: "start-failed",
@@ -42083,6 +42168,9 @@ var EngineServer = class {
42083
42168
  return Promise.reject(error);
42084
42169
  }
42085
42170
  }
42171
+ changed(linkId) {
42172
+ this.changedAt.set(linkId, ++this.changes);
42173
+ }
42086
42174
  track(linkId, running) {
42087
42175
  const settled = running.catch(() => {});
42088
42176
  this.sending.set(linkId, settled);
@@ -42148,4 +42236,4 @@ function signalKind(signal) {
42148
42236
  //#endregion
42149
42237
  export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
42150
42238
 
42151
- //# sourceMappingURL=server-GJW_5wGI.js.map
42239
+ //# sourceMappingURL=server-BGXmSYx_.js.map