@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.
- package/README.md +16 -11
- package/SKILL.md +2 -2
- package/dist/assets/arkade-3szZ1IpP.js +362 -0
- package/dist/assets/arkade-3szZ1IpP.js.map +1 -0
- package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
- package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
- package/dist/assets/{fileBytes-3AQ2tJzW.js → fileBytes-BcYZhNgF.js} +3 -3
- package/dist/assets/{fileBytes-3AQ2tJzW.js.map → fileBytes-BcYZhNgF.js.map} +1 -1
- package/dist/assets/{fileBytes-CAZDydIe.js → fileBytes-Dbifu5I5.js} +2 -2
- package/dist/assets/{fileBytes-CAZDydIe.js.map → fileBytes-Dbifu5I5.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-CmG00qE-.js → fileBytesIdb-DzSaqI0A.js} +3 -3
- package/dist/assets/{fileBytesIdb-CmG00qE-.js.map → fileBytesIdb-DzSaqI0A.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-CTgqt-p3.js → fileBytesOpfs-Dx8RZiV5.js} +3 -3
- package/dist/assets/{fileBytesOpfs-CTgqt-p3.js.map → fileBytesOpfs-Dx8RZiV5.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-DvXgebV7.js → hyperdhtRelay-DmTrpmba.js} +3 -3
- package/dist/assets/{hyperdhtRelay-DvXgebV7.js.map → hyperdhtRelay-DmTrpmba.js.map} +1 -1
- package/dist/assets/{idb-BIdus0WX.js → idb-CTF6LF5B.js} +62 -6
- package/dist/assets/idb-CTF6LF5B.js.map +1 -0
- package/dist/assets/{mainline-B7wHcLaW.js → mainline-BueFJx3f.js} +2 -2
- package/dist/assets/{mainline-B7wHcLaW.js.map → mainline-BueFJx3f.js.map} +1 -1
- package/dist/assets/{restoredRows-lhTKGmCO.js → restoredRows-C_84q0Pd.js} +2 -2
- package/dist/assets/{restoredRows-lhTKGmCO.js.map → restoredRows-C_84q0Pd.js.map} +1 -1
- package/dist/assets/{server-GJW_5wGI.js → server-BGXmSYx_.js} +1124 -1036
- package/dist/assets/server-BGXmSYx_.js.map +1 -0
- package/dist/assets/{src-Cp3o_yHN.js → src-BMUcYQ7b.js} +750 -242
- package/dist/assets/src-BMUcYQ7b.js.map +1 -0
- package/dist/assets/usdt-DklGBSeq.js +410 -0
- package/dist/assets/usdt-DklGBSeq.js.map +1 -0
- package/dist/ghostly.mjs +858 -137
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +4 -4
- package/dist/assets/endpoint-D_3bYafG.js.map +0 -1
- package/dist/assets/idb-BIdus0WX.js.map +0 -1
- package/dist/assets/server-GJW_5wGI.js.map +0 -1
- 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
|
|
3
|
-
import { a as clearProfileStores, c as fileStore, d as
|
|
4
|
-
import { a as fileBytesOf, c as removeFileBytes, i as fileBytes, l as FILE_BYTES_STEP, n as blobDigest } from "./fileBytes-
|
|
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,
|
|
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
|
|
18
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
12077
|
-
const check = checkAppBeforeRun(version,
|
|
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
|
-
|
|
11461
|
+
verifiedBundle(app) {
|
|
12140
11462
|
const kept = this.verified.get(app.digest);
|
|
12141
|
-
if (kept) return kept;
|
|
12142
|
-
const
|
|
12143
|
-
if (
|
|
12144
|
-
const
|
|
12145
|
-
|
|
12146
|
-
|
|
12147
|
-
|
|
12148
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
12164
|
-
|
|
12165
|
-
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
|
|
12169
|
-
|
|
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
|
|
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
|
|
12332
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
12442
|
-
|
|
12443
|
-
|
|
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 &&
|
|
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
|
-
/**
|
|
12457
|
-
|
|
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
|
-
|
|
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
|
|
12508
|
-
*
|
|
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
|
|
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
|
|
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))
|
|
12542
|
-
|
|
12543
|
-
|
|
12544
|
-
peek
|
|
12545
|
-
|
|
12546
|
-
|
|
12547
|
-
|
|
12548
|
-
|
|
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
|
-
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
12598
|
-
await this.
|
|
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
|
|
12620
|
-
const
|
|
12621
|
-
|
|
12622
|
-
|
|
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.
|
|
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
|
|
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)
|
|
12649
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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)
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21627
|
-
|
|
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")).
|
|
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`,
|
|
21828
|
-
*
|
|
21829
|
-
*
|
|
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)
|
|
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
|
|
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)
|
|
21938
|
-
const
|
|
21939
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
25475
|
-
|
|
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
|
-
|
|
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")
|
|
25840
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
26247
|
-
*
|
|
26248
|
-
*
|
|
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
|
-
|
|
26267
|
-
|
|
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(
|
|
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
|
|
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
|
|
28086
|
-
const
|
|
28087
|
-
const
|
|
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
|
|
27966
|
+
name,
|
|
28093
27967
|
size: meta.size,
|
|
28094
|
-
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
|
-
/**
|
|
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
|
|
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
|
|
29517
|
-
feesPaid:
|
|
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
|
-
|
|
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
|
|
30161
|
-
*
|
|
30162
|
-
*
|
|
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()
|
|
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
|
|
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
|
-
/**
|
|
31547
|
-
|
|
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() ||
|
|
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
|
|
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
|
|
34328
|
+
const live = this.links.get(linkId);
|
|
34425
34329
|
return {
|
|
34426
|
-
mine:
|
|
34427
|
-
theirs: stored
|
|
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
|
|
34370
|
+
const live = this.links.get(linkId), stored = live?.stored;
|
|
34467
34371
|
if (!stored?.profile || !stored.identities) return [];
|
|
34468
|
-
const mine =
|
|
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 =
|
|
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([
|
|
35530
|
-
|
|
35531
|
-
|
|
35532
|
-
|
|
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
|
|
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
|
|
36702
|
-
|
|
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
|
|
36879
|
-
*
|
|
36880
|
-
*
|
|
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
|
|
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
|
-
|
|
36890
|
-
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
38434
|
-
|
|
38435
|
-
|
|
38436
|
-
|
|
38437
|
-
|
|
38438
|
-
|
|
38439
|
-
|
|
38440
|
-
|
|
38441
|
-
|
|
38442
|
-
|
|
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 ??
|
|
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
|
|
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
|
|
40869
|
-
|
|
40883
|
+
if (status !== live.status) {
|
|
40884
|
+
live.status = status;
|
|
40885
|
+
this.emitState();
|
|
40886
|
+
}
|
|
40870
40887
|
},
|
|
40871
40888
|
onDiscoveryError: (error) => {
|
|
40872
|
-
|
|
40873
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41090
|
-
|
|
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
|
-
|
|
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 (
|
|
41131
|
-
if (
|
|
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-
|
|
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.
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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:
|
|
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) =>
|
|
41977
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
42017
|
-
if (
|
|
42018
|
-
|
|
42019
|
-
|
|
42020
|
-
|
|
42021
|
-
|
|
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
|
-
|
|
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-
|
|
42239
|
+
//# sourceMappingURL=server-BGXmSYx_.js.map
|