@ghostlytools/cli 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/{endpoint-D_3bYafG.js → endpoint-pgFkR2AS.js} +47 -10
- package/dist/assets/endpoint-pgFkR2AS.js.map +1 -0
- package/dist/assets/fedimint_client_wasm_bg-BtHHlFpp.js.map +1 -1
- 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-CnEkFl8R.js → hyperdhtRelay-DmTrpmba.js} +3 -3
- package/dist/assets/{hyperdhtRelay-CnEkFl8R.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-DQxDSWXm.js → mainline-BueFJx3f.js} +2 -2
- package/dist/assets/{mainline-DQxDSWXm.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-HMzdsyn_.js → server-BGXmSYx_.js} +1170 -1049
- package/dist/assets/server-BGXmSYx_.js.map +1 -0
- package/dist/assets/{src-DtxVo2Rm.js → src-BMUcYQ7b.js} +770 -244
- 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-HMzdsyn_.js.map +0 -1
- package/dist/assets/src-DtxVo2Rm.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.js";
|
|
2
|
-
import { $i as
|
|
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);
|
|
@@ -4392,14 +3639,14 @@ var BitcoinService = class {
|
|
|
4392
3639
|
return this.target(await provider.receiveAddress(), (await provider.info()).network);
|
|
4393
3640
|
}
|
|
4394
3641
|
/**
|
|
4395
|
-
* A transaction of the active source paying `target` at least `amount`, none of `claimed
|
|
4396
|
-
* that
|
|
4397
|
-
*
|
|
3642
|
+
* A transaction of the active source paying `target` at least `amount`, none of `claimed`: what the source
|
|
3643
|
+
* says that address received is the only proof. A contact's txid proves nothing (it can name any receive of
|
|
3644
|
+
* the wallet), so a source that cannot list an address's receives never sees a request paid.
|
|
4398
3645
|
*/
|
|
4399
|
-
async received(target, amount, claimed
|
|
3646
|
+
async received(target, amount, claimed) {
|
|
4400
3647
|
const provider = this.sources.active;
|
|
4401
|
-
if (!provider || target.method !== "bitcoin" || (await provider.info()).network !== target.network) return void 0;
|
|
4402
|
-
const tx = (
|
|
3648
|
+
if (!provider?.received || target.method !== "bitcoin" || (await provider.info()).network !== target.network) return void 0;
|
|
3649
|
+
const tx = (await provider.received(target.address)).find((tx) => tx.amount >= amount && !claimed.has(tx.txid) && /^[0-9a-f]{64}$/.test(tx.txid));
|
|
4403
3650
|
return tx && {
|
|
4404
3651
|
txid: tx.txid,
|
|
4405
3652
|
confirmations: tx.confirmations
|
|
@@ -5587,7 +4834,7 @@ var BitcoindOnchain = class BitcoindOnchain {
|
|
|
5587
4834
|
lockUnspents: true,
|
|
5588
4835
|
replaceable: true
|
|
5589
4836
|
}), "funded transaction");
|
|
5590
|
-
const inputs = this.outpoints(Transaction
|
|
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
|
}
|
|
@@ -5748,6 +4996,24 @@ var BitcoindOnchain = class BitcoindOnchain {
|
|
|
5748
4996
|
}
|
|
5749
4997
|
return [...byTxid.values()].reverse().slice(0, Math.max(0, limit));
|
|
5750
4998
|
}
|
|
4999
|
+
/** The wallet's receives on this address, newest first: only `receive` entries paying exactly it count. */
|
|
5000
|
+
async received(address) {
|
|
5001
|
+
const entries = await this.call("listtransactions", "*", 200, 0, true);
|
|
5002
|
+
if (!Array.isArray(entries)) throw new Error("The node returned no transaction list");
|
|
5003
|
+
const byTxid = /* @__PURE__ */ new Map();
|
|
5004
|
+
for (const raw of entries) {
|
|
5005
|
+
if (!raw || raw.category !== "receive" || raw.address !== address || typeof raw.txid !== "string" || !TXID.test(raw.txid)) continue;
|
|
5006
|
+
const tx = byTxid.get(raw.txid) ?? {
|
|
5007
|
+
txid: raw.txid,
|
|
5008
|
+
amount: 0,
|
|
5009
|
+
confirmations: typeof raw.confirmations === "number" ? Math.max(0, raw.confirmations) : 0
|
|
5010
|
+
};
|
|
5011
|
+
tx.amount += btcToSats(raw.amount);
|
|
5012
|
+
byTxid.delete(raw.txid);
|
|
5013
|
+
byTxid.set(raw.txid, tx);
|
|
5014
|
+
}
|
|
5015
|
+
return [...byTxid.values()].reverse();
|
|
5016
|
+
}
|
|
5751
5017
|
async close() {
|
|
5752
5018
|
this.closed = true;
|
|
5753
5019
|
}
|
|
@@ -11885,6 +11151,16 @@ var STAGED = {
|
|
|
11885
11151
|
/** The first bytes of a bundle: magic, the manifest's length and the largest manifest. */
|
|
11886
11152
|
var PEEK_BYTES = APP_BUNDLE_MAGIC.length + 4 + APP_BUNDLE_LIMITS.manifestBytes;
|
|
11887
11153
|
var bundleId = (digest) => `app-${digest}`;
|
|
11154
|
+
/**
|
|
11155
|
+
* A listing's URLs this client reads, in order, a jsDelivr copy first: one this client reads names a full commit, so it
|
|
11156
|
+
* holds exactly the version the store reviewed, where a raw `HEAD` may have moved on to one it has not (WISP 1200 §
|
|
11157
|
+
* Stores), and a 16 MiB bundle is not downloaded only to be skipped.
|
|
11158
|
+
*/
|
|
11159
|
+
function listingUrls(urls) {
|
|
11160
|
+
const readable = urls.filter(isAppFetchUrl);
|
|
11161
|
+
const pinned = (u) => new URL(u).hostname.toLowerCase() === "cdn.jsdelivr.net";
|
|
11162
|
+
return [...readable.filter(pinned), ...readable.filter((u) => !pinned(u))];
|
|
11163
|
+
}
|
|
11888
11164
|
/** An error whose message starts with its code (`<code>: words`), as it crosses the engine's RPC. */
|
|
11889
11165
|
function fail(code, words) {
|
|
11890
11166
|
throw new Error(`${code}: ${words}`);
|
|
@@ -11903,6 +11179,15 @@ var versionOf = (manifest, digest) => ({
|
|
|
11903
11179
|
digest
|
|
11904
11180
|
});
|
|
11905
11181
|
var byteLength = (text) => utf8Encode(text).length;
|
|
11182
|
+
/** The app with these revocations merged into the ones it holds, each kept once. */
|
|
11183
|
+
function withRevocations(app, read) {
|
|
11184
|
+
const merged = new Map((app.revocations ?? []).map((r) => [canonicalJson(r), r]));
|
|
11185
|
+
for (const r of read) merged.set(canonicalJson(r), r);
|
|
11186
|
+
return merged.size ? {
|
|
11187
|
+
...app,
|
|
11188
|
+
revocations: [...merged.values()]
|
|
11189
|
+
} : app;
|
|
11190
|
+
}
|
|
11906
11191
|
/** Rows of one app (`[ref]`), or of one app in one scope (`[ref, scope]`), in `STORES.appStorage`. */
|
|
11907
11192
|
var rowsOf = (...prefix) => IDBKeyRange.bound(prefix, [...prefix, []]);
|
|
11908
11193
|
function done(tx) {
|
|
@@ -11939,13 +11224,28 @@ var Apps = class {
|
|
|
11939
11224
|
staged = /* @__PURE__ */ new Map();
|
|
11940
11225
|
/** Bundles read back and checked, by digest: the one running is read once, not on every `file`. */
|
|
11941
11226
|
verified = /* @__PURE__ */ new Map();
|
|
11227
|
+
/** Reads of a bundle under way, by digest: icons drawn at once share one read. */
|
|
11228
|
+
verifying = /* @__PURE__ */ new Map();
|
|
11229
|
+
/**
|
|
11230
|
+
* The `icon.png` of each checked bundle, by digest (at most 256 KiB, or null for none): every icon shown on the Apps
|
|
11231
|
+
* page, in a chat's cards and in the composer's Apps is read without the whole bundle again.
|
|
11232
|
+
*/
|
|
11233
|
+
icons = /* @__PURE__ */ new Map();
|
|
11942
11234
|
/** Bytes used per `ref\0scope`, counted once from the rows and kept in step by the writes here. */
|
|
11943
11235
|
usage = /* @__PURE__ */ new Map();
|
|
11944
11236
|
queues = /* @__PURE__ */ new Map();
|
|
11945
11237
|
timer = null;
|
|
11238
|
+
/** Drops what `staged` holds past `STAGED.ms`, set while it holds anything: a closed install screen sends no call. */
|
|
11239
|
+
sweep = null;
|
|
11946
11240
|
checking = null;
|
|
11947
11241
|
/** Runs the person started with Run anyway (`<ref> <digest>`), for this engine's life: a removal does not cut them off. */
|
|
11948
11242
|
anyway = /* @__PURE__ */ new Set();
|
|
11243
|
+
views = null;
|
|
11244
|
+
/**
|
|
11245
|
+
* Listing URLs whose peek showed nothing to take (`<store> <ref> <url>` → `<index digest> <listed digest>`): the same
|
|
11246
|
+
* index listing the same is not peeked again at every check.
|
|
11247
|
+
*/
|
|
11248
|
+
listingMisses = /* @__PURE__ */ new Map();
|
|
11949
11249
|
stopped = false;
|
|
11950
11250
|
constructor(host) {
|
|
11951
11251
|
this.host = host;
|
|
@@ -11966,6 +11266,9 @@ var Apps = class {
|
|
|
11966
11266
|
this.stopped = true;
|
|
11967
11267
|
if (this.timer) clearTimeout(this.timer);
|
|
11968
11268
|
this.timer = null;
|
|
11269
|
+
if (this.sweep) clearTimeout(this.sweep);
|
|
11270
|
+
this.sweep = null;
|
|
11271
|
+
this.staged.clear();
|
|
11969
11272
|
}
|
|
11970
11273
|
schedule(ms, atStart = false) {
|
|
11971
11274
|
if (this.timer) clearTimeout(this.timer);
|
|
@@ -12010,7 +11313,7 @@ var Apps = class {
|
|
|
12010
11313
|
* or not the page stops its frame.
|
|
12011
11314
|
*/
|
|
12012
11315
|
async stillRunnable(app) {
|
|
12013
|
-
const run = this.runStatus(app, await this.
|
|
11316
|
+
const run = this.runStatus(app, await this.heldViews(), app.revocations);
|
|
12014
11317
|
if (run.status === "revoked") fail("stopped", "Its publisher revoked this version");
|
|
12015
11318
|
if (run.status === "removed" && !this.anyway.has(`${app.ref} ${app.digest}`)) fail("stopped", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12016
11319
|
return app;
|
|
@@ -12029,11 +11332,42 @@ var Apps = class {
|
|
|
12029
11332
|
async putApp(app) {
|
|
12030
11333
|
await wrap((await store(STORES$1.apps, "readwrite")).put(app));
|
|
12031
11334
|
}
|
|
11335
|
+
/**
|
|
11336
|
+
* Writes an app's record back only while that install is still there (the same `installedAt`), read and written in
|
|
11337
|
+
* one transaction: an app uninstalled (or installed again) while the update check was reading is not put back.
|
|
11338
|
+
*/
|
|
11339
|
+
async putAppIfInstalled(app, installedAt) {
|
|
11340
|
+
const tx = (await openDb()).transaction(STORES$1.apps, "readwrite");
|
|
11341
|
+
let put = false;
|
|
11342
|
+
const read = tx.objectStore(STORES$1.apps).get(app.ref);
|
|
11343
|
+
read.onsuccess = () => {
|
|
11344
|
+
if (read.result?.installedAt !== installedAt) return;
|
|
11345
|
+
tx.objectStore(STORES$1.apps).put(app);
|
|
11346
|
+
put = true;
|
|
11347
|
+
};
|
|
11348
|
+
await done(tx);
|
|
11349
|
+
return put;
|
|
11350
|
+
}
|
|
12032
11351
|
async storeRecord(key) {
|
|
12033
11352
|
return wrap((await store(STORES$1.appStores, "readonly")).get(key));
|
|
12034
11353
|
}
|
|
12035
11354
|
async putStore(record) {
|
|
12036
11355
|
await wrap((await store(STORES$1.appStores, "readwrite")).put(record));
|
|
11356
|
+
this.views = null;
|
|
11357
|
+
}
|
|
11358
|
+
async deleteStore(key) {
|
|
11359
|
+
await wrap((await store(STORES$1.appStores, "readwrite")).delete(key));
|
|
11360
|
+
this.views = null;
|
|
11361
|
+
}
|
|
11362
|
+
/**
|
|
11363
|
+
* The stores' views, read once and again after a store is written here (the only writer: pages in other tabs share
|
|
11364
|
+
* this engine). An app's every storage call and chat frame checks them, and an index may be 4 MiB.
|
|
11365
|
+
*/
|
|
11366
|
+
heldViews() {
|
|
11367
|
+
return this.views ??= this.stores().then((s) => this.storeViews(s), (error) => {
|
|
11368
|
+
this.views = null;
|
|
11369
|
+
throw error;
|
|
11370
|
+
});
|
|
12037
11371
|
}
|
|
12038
11372
|
/** The stores the person has (a removed default is not one). */
|
|
12039
11373
|
async stores() {
|
|
@@ -12055,8 +11389,8 @@ var Apps = class {
|
|
|
12055
11389
|
kind: s.index.kind
|
|
12056
11390
|
}));
|
|
12057
11391
|
}
|
|
12058
|
-
runStatus(version,
|
|
12059
|
-
const check = checkAppBeforeRun(version,
|
|
11392
|
+
runStatus(version, views, revocations = []) {
|
|
11393
|
+
const check = checkAppBeforeRun(version, views, revocations);
|
|
12060
11394
|
if (check.status === "revoked") return {
|
|
12061
11395
|
status: "revoked",
|
|
12062
11396
|
...check.revocation.statement.reason && { reason: check.revocation.statement.reason }
|
|
@@ -12115,19 +11449,34 @@ var Apps = class {
|
|
|
12115
11449
|
}
|
|
12116
11450
|
async removeBundle(kind, digest) {
|
|
12117
11451
|
this.verified.delete(digest);
|
|
11452
|
+
this.icons.delete(digest);
|
|
12118
11453
|
await (await fileBytesOf(kind))?.remove(bundleId(digest)).catch(() => {});
|
|
12119
11454
|
}
|
|
11455
|
+
/** A bundle's bytes removed unless the app's record (installed again meanwhile) holds that version, or waits for it. */
|
|
11456
|
+
async removeUnusedBundle(ref, kind, digest) {
|
|
11457
|
+
const app = await this.app(ref);
|
|
11458
|
+
if (app?.digest !== digest && app?.pending?.digest !== digest) await this.removeBundle(kind, digest);
|
|
11459
|
+
}
|
|
12120
11460
|
/** The installed bundle read back and checked again before it runs (WISP 1200 § Signatures: "again before it runs one"). */
|
|
12121
|
-
|
|
11461
|
+
verifiedBundle(app) {
|
|
12122
11462
|
const kept = this.verified.get(app.digest);
|
|
12123
|
-
if (kept) return kept;
|
|
12124
|
-
const
|
|
12125
|
-
if (
|
|
12126
|
-
const
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
12130
|
-
|
|
11463
|
+
if (kept) return Promise.resolve(kept);
|
|
11464
|
+
const under = this.verifying.get(app.digest);
|
|
11465
|
+
if (under) return under;
|
|
11466
|
+
const reading = (async () => {
|
|
11467
|
+
const bytes = await this.readBundleBytes(app.bytes, app.digest);
|
|
11468
|
+
if (!bytes) fail("needs-files", "This app's files are not on this device yet");
|
|
11469
|
+
const read = readAppBundle(bytes);
|
|
11470
|
+
if (!read.ok || read.bundle.digest !== app.digest) fail("damaged", "This app's stored files do not match what was installed");
|
|
11471
|
+
if (this.verified.size >= 2) this.verified.delete(this.verified.keys().next().value);
|
|
11472
|
+
this.verified.set(app.digest, read.bundle);
|
|
11473
|
+
this.icons.set(app.digest, read.bundle.files.get("icon.png")?.slice() ?? null);
|
|
11474
|
+
return read.bundle;
|
|
11475
|
+
})().finally(() => {
|
|
11476
|
+
this.verifying.delete(app.digest);
|
|
11477
|
+
});
|
|
11478
|
+
this.verifying.set(app.digest, reading);
|
|
11479
|
+
return reading;
|
|
12131
11480
|
}
|
|
12132
11481
|
async fetchBundle(url) {
|
|
12133
11482
|
const bytes = await this.host.fetch(url, { maxBytes: APP_BUNDLE_LIMITS.bundleBytes });
|
|
@@ -12138,17 +11487,36 @@ var Apps = class {
|
|
|
12138
11487
|
bytes
|
|
12139
11488
|
};
|
|
12140
11489
|
}
|
|
12141
|
-
|
|
11490
|
+
/**
|
|
11491
|
+
* Reads the index at `url`. With the index held for this store, bytes equal to it (by digest) are the held index: its
|
|
11492
|
+
* signature and every revocation in it were checked when it was read, so only its expiry is taken again.
|
|
11493
|
+
*/
|
|
11494
|
+
async readStoreAt(url, heldKey, held) {
|
|
12142
11495
|
const [indexBytes, sigBytes] = await Promise.all([this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.storeIndexBytes }), this.host.fetch(besideUrl(url, "ghostly-store.sig"), { maxBytes: APP_FETCH_LIMITS.sigBytes })]);
|
|
11496
|
+
if (held?.index && held.digest !== void 0 && toBase64Url(sha256(indexBytes)) === held.digest) return {
|
|
11497
|
+
ok: true,
|
|
11498
|
+
store: {
|
|
11499
|
+
index: held.index,
|
|
11500
|
+
digest: held.digest,
|
|
11501
|
+
expired: held.index.expires < this.nowS()
|
|
11502
|
+
}
|
|
11503
|
+
};
|
|
12143
11504
|
return readAppStore(indexBytes, sigBytes, this.nowS(), heldKey);
|
|
12144
11505
|
}
|
|
12145
|
-
/**
|
|
12146
|
-
|
|
12147
|
-
|
|
12148
|
-
|
|
12149
|
-
|
|
12150
|
-
|
|
12151
|
-
|
|
11506
|
+
/**
|
|
11507
|
+
* The revocations its publisher signed, read beside each URL (`ghostly-revoke.json`); none there is not an error.
|
|
11508
|
+
* `read` holds the files already read, which are skipped, and gets the ones read now. Null when no file answered now.
|
|
11509
|
+
*/
|
|
11510
|
+
async readRevocations(ref, urls, read = /* @__PURE__ */ new Set()) {
|
|
11511
|
+
let out = null;
|
|
11512
|
+
for (const url of new Set(urls.filter(isAppFetchUrl).map((u) => besideUrl(u, "ghostly-revoke.json")))) {
|
|
11513
|
+
if (read.has(url)) continue;
|
|
11514
|
+
read.add(url);
|
|
11515
|
+
try {
|
|
11516
|
+
const read = readAppRevocations(await this.host.fetch(url, { maxBytes: APP_FETCH_LIMITS.revocationsBytes }));
|
|
11517
|
+
if (read.ok) (out ??= []).push(...read.revocations.filter((r) => r.statement.app === ref));
|
|
11518
|
+
} catch {}
|
|
11519
|
+
}
|
|
12152
11520
|
return out;
|
|
12153
11521
|
}
|
|
12154
11522
|
summary(s) {
|
|
@@ -12281,7 +11649,12 @@ var Apps = class {
|
|
|
12281
11649
|
preloaded: true,
|
|
12282
11650
|
removed: true
|
|
12283
11651
|
});
|
|
12284
|
-
else await
|
|
11652
|
+
else await this.deleteStore(key);
|
|
11653
|
+
for (const app of await this.installed()) {
|
|
11654
|
+
if (app.store !== key) continue;
|
|
11655
|
+
const { store: _s, ...unpinned } = app;
|
|
11656
|
+
await this.putApp(unpinned);
|
|
11657
|
+
}
|
|
12285
11658
|
}
|
|
12286
11659
|
/** Reads one store again, or every store (the person opened the Apps page, or the update check). */
|
|
12287
11660
|
async refreshStores({ key } = {}) {
|
|
@@ -12291,7 +11664,7 @@ var Apps = class {
|
|
|
12291
11664
|
for (const s of stores) {
|
|
12292
11665
|
let next;
|
|
12293
11666
|
try {
|
|
12294
|
-
const read = await this.readStoreAt(s.url, s.key);
|
|
11667
|
+
const read = await this.readStoreAt(s.url, s.key, s);
|
|
12295
11668
|
next = read.ok ? this.withIndex(s, read.store.index, read.store.digest) : {
|
|
12296
11669
|
...s,
|
|
12297
11670
|
problem: read.reason
|
|
@@ -12310,23 +11683,28 @@ var Apps = class {
|
|
|
12310
11683
|
}
|
|
12311
11684
|
/**
|
|
12312
11685
|
* Fetches an app and checks it, for the install screen: from a pasted URL, a store's listing (its URLs in order) or a
|
|
12313
|
-
* chat card's pointer. A
|
|
12314
|
-
*
|
|
11686
|
+
* chat card's pointer. A card's `sequence` and digest must hold: the same app, and that version or a newer one. A
|
|
11687
|
+
* store's listing must hold exactly: the version it lists, by digest, so a URL that already holds a newer version the
|
|
11688
|
+
* store has not reviewed is skipped for the next one (`unlisted` when none holds it). Nothing is stored: the checked
|
|
11689
|
+
* bundle waits in memory for `install`.
|
|
12315
11690
|
*/
|
|
12316
11691
|
async preview(source) {
|
|
12317
11692
|
let urls;
|
|
12318
11693
|
let expect = null;
|
|
11694
|
+
let fromStore;
|
|
12319
11695
|
if ("url" in source) urls = [typeof source.url === "string" && appPasteUrl(source.url, "app.ghostlyapp") || notReadable(source.url, "Apps are read only from raw.githubusercontent.com, or from cdn.jsdelivr.net at a commit, over HTTPS")];
|
|
12320
11696
|
else if ("store" in source) {
|
|
12321
11697
|
const s = await this.storeRecord(source.store);
|
|
12322
11698
|
const listing = s && !s.removed ? s.index?.apps.find((a) => a.ref === source.ref) : void 0;
|
|
12323
11699
|
if (!listing) fail("not-listed", "This store does not list this app");
|
|
12324
|
-
urls = listing.urls
|
|
11700
|
+
urls = listingUrls(listing.urls);
|
|
12325
11701
|
expect = {
|
|
12326
11702
|
ref: listing.ref,
|
|
12327
11703
|
sequence: listing.sequence,
|
|
12328
|
-
digest: listing.digest
|
|
11704
|
+
digest: listing.digest,
|
|
11705
|
+
exact: true
|
|
12329
11706
|
};
|
|
11707
|
+
fromStore = s.key;
|
|
12330
11708
|
} else if ("card" in source && source.card) {
|
|
12331
11709
|
const { ref, url, sequence, digest } = source.card;
|
|
12332
11710
|
if (!isAppRef(ref)) fail("bad-ref", "Not an app reference");
|
|
@@ -12347,14 +11725,22 @@ var Apps = class {
|
|
|
12347
11725
|
if (version.ref !== expect.ref) fail("other-app", "The URL holds another app");
|
|
12348
11726
|
if (expect.sequence !== void 0 && version.sequence < expect.sequence) fail("rollback", "The URL holds an older version than the one named");
|
|
12349
11727
|
if (expect.sequence === version.sequence && expect.digest !== void 0 && expect.digest !== version.digest) fail("equivocation", "The URL holds another version under the same number");
|
|
11728
|
+
if (expect.exact && version.digest !== expect.digest) fail("unlisted", "The URL holds a version the store does not list");
|
|
12350
11729
|
}
|
|
12351
|
-
return this.stage(bundle, bytes, url);
|
|
11730
|
+
return this.stage(bundle, bytes, url, fromStore);
|
|
12352
11731
|
} catch (error) {
|
|
12353
11732
|
last = error;
|
|
12354
11733
|
}
|
|
12355
11734
|
throw last instanceof Error ? last : new Error(String(last));
|
|
12356
11735
|
}
|
|
12357
|
-
|
|
11736
|
+
/** Drops the staged bundles past `STAGED.ms`, and comes back when the oldest left is due. */
|
|
11737
|
+
sweepStaged() {
|
|
11738
|
+
this.sweep = null;
|
|
11739
|
+
for (const [digest, s] of this.staged) if (this.now() - s.at >= STAGED.ms) this.staged.delete(digest);
|
|
11740
|
+
const oldest = this.staged.values().next().value;
|
|
11741
|
+
if (oldest) this.sweep = setTimeout(() => this.sweepStaged(), STAGED.ms - (this.now() - oldest.at));
|
|
11742
|
+
}
|
|
11743
|
+
async stage(bundle, bytes, from, fromStore) {
|
|
12358
11744
|
for (const [digest, s] of this.staged) if (this.now() - s.at > STAGED.ms) this.staged.delete(digest);
|
|
12359
11745
|
this.staged.delete(bundle.digest);
|
|
12360
11746
|
while (this.staged.size >= STAGED.max) this.staged.delete(this.staged.keys().next().value);
|
|
@@ -12362,8 +11748,10 @@ var Apps = class {
|
|
|
12362
11748
|
bundle,
|
|
12363
11749
|
bytes,
|
|
12364
11750
|
from,
|
|
11751
|
+
...fromStore !== void 0 && { store: fromStore },
|
|
12365
11752
|
at: this.now()
|
|
12366
11753
|
});
|
|
11754
|
+
if (!this.sweep) this.sweepStaged();
|
|
12367
11755
|
const { manifest, digest } = bundle;
|
|
12368
11756
|
const version = versionOf(manifest, digest);
|
|
12369
11757
|
const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
|
|
@@ -12381,7 +11769,7 @@ var Apps = class {
|
|
|
12381
11769
|
icon: bundle.files.get("icon.png")?.slice() ?? null,
|
|
12382
11770
|
install,
|
|
12383
11771
|
asks,
|
|
12384
|
-
run: this.runStatus(version, stores, installed?.revocations),
|
|
11772
|
+
run: this.runStatus(version, this.storeViews(stores), installed?.revocations),
|
|
12385
11773
|
listedBy,
|
|
12386
11774
|
unknownPublisher: !listedBy.some((s) => s.kind === "curated")
|
|
12387
11775
|
};
|
|
@@ -12397,12 +11785,12 @@ var Apps = class {
|
|
|
12397
11785
|
this.staged.delete(digest);
|
|
12398
11786
|
fail("expired", "Fetch the app again");
|
|
12399
11787
|
}
|
|
12400
|
-
const { bundle, bytes, from } = staged;
|
|
11788
|
+
const { bundle, bytes, from, store: fromStore } = staged;
|
|
12401
11789
|
const { manifest } = bundle;
|
|
12402
11790
|
if (!Array.isArray(grant) || !manifest.permissions.every((p) => grant.includes(p))) fail("permissions", "Every permission the app asks for must be granted");
|
|
12403
11791
|
const version = versionOf(manifest, digest);
|
|
12404
11792
|
const [installed, stores] = await Promise.all([this.app(version.ref), this.stores()]);
|
|
12405
|
-
const run = this.runStatus(version, stores, installed?.revocations);
|
|
11793
|
+
const run = this.runStatus(version, this.storeViews(stores), installed?.revocations);
|
|
12406
11794
|
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12407
11795
|
if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12408
11796
|
const pendingClash = installed?.pending && installed.pending.sequence === version.sequence && installed.pending.digest !== digest;
|
|
@@ -12420,26 +11808,37 @@ var Apps = class {
|
|
|
12420
11808
|
fail("equivocation", `Two different versions ${version.sequence} exist. Ghostly kept the one you have.`);
|
|
12421
11809
|
}
|
|
12422
11810
|
if (decision === "same" && installed) {
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12426
|
-
|
|
11811
|
+
const files = await this.hasBundle(installed.bytes, digest) ? null : await this.writeBundle(digest, bytes);
|
|
11812
|
+
const pin = fromStore !== void 0 && installed.store !== fromStore;
|
|
11813
|
+
if (files || pin) {
|
|
11814
|
+
const { pending, ...rest } = installed;
|
|
11815
|
+
if (pin && pending) await this.removeBundle(pending.bytes, pending.digest);
|
|
11816
|
+
await this.putApp({
|
|
11817
|
+
...pin ? rest : installed,
|
|
11818
|
+
...files && { bytes: files },
|
|
11819
|
+
...pin && { store: fromStore }
|
|
11820
|
+
});
|
|
11821
|
+
}
|
|
12427
11822
|
this.staged.delete(digest);
|
|
12428
11823
|
return this.view(await this.app(version.ref), stores);
|
|
12429
11824
|
}
|
|
12430
11825
|
const kind = await this.writeBundle(digest, bytes);
|
|
12431
|
-
const next = this.versionRecord(installed, bundle, kind, from);
|
|
11826
|
+
const next = this.versionRecord(installed, bundle, kind, from, fromStore);
|
|
12432
11827
|
await this.putApp(next);
|
|
12433
11828
|
if (installed && installed.digest !== digest) await this.removeBundle(installed.bytes, installed.digest);
|
|
12434
|
-
if (installed?.pending &&
|
|
11829
|
+
if (installed?.pending && next.pending?.digest !== installed.pending.digest && installed.pending.digest !== digest) await this.removeBundle(installed.pending.bytes, installed.pending.digest);
|
|
12435
11830
|
this.staged.delete(digest);
|
|
12436
11831
|
return this.view(next, stores);
|
|
12437
11832
|
}
|
|
12438
|
-
/**
|
|
12439
|
-
|
|
11833
|
+
/**
|
|
11834
|
+
* The record of a version installed now, keeping what the app had (its revocations, a newer version waiting). `store`
|
|
11835
|
+
* is the store its updates come from (`InstalledApp.store`); a newer version waiting is kept only while that is
|
|
11836
|
+
* unchanged, since it was found under the other rule.
|
|
11837
|
+
*/
|
|
11838
|
+
versionRecord(installed, bundle, kind, from, store) {
|
|
12440
11839
|
const { manifest, digest } = bundle;
|
|
12441
11840
|
const now = this.now();
|
|
12442
|
-
const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence ? installed.pending : void 0;
|
|
11841
|
+
const keepPending = installed?.pending && installed.pending.sequence > manifest.sequence && installed.store === store ? installed.pending : void 0;
|
|
12443
11842
|
return {
|
|
12444
11843
|
ref: appRef(manifest.publisher, manifest.name),
|
|
12445
11844
|
publisher: manifest.publisher,
|
|
@@ -12450,6 +11849,7 @@ var Apps = class {
|
|
|
12450
11849
|
manifest,
|
|
12451
11850
|
bytes: kind,
|
|
12452
11851
|
from,
|
|
11852
|
+
...store !== void 0 && { store },
|
|
12453
11853
|
installedAt: installed?.installedAt ?? now,
|
|
12454
11854
|
updatedAt: now,
|
|
12455
11855
|
...installed?.revocations?.length && { revocations: installed.revocations },
|
|
@@ -12470,7 +11870,8 @@ var Apps = class {
|
|
|
12470
11870
|
fail("needs-files", "The update's files are gone; it is fetched again at the next check");
|
|
12471
11871
|
}
|
|
12472
11872
|
const stores = await this.stores();
|
|
12473
|
-
|
|
11873
|
+
if (installed.store !== void 0 && this.pinnedListing(installed, stores)?.digest !== pending.digest) fail("unlisted", "The store no longer lists this update");
|
|
11874
|
+
const run = this.runStatus(versionOf(read.bundle.manifest, pending.digest), this.storeViews(stores), installed.revocations);
|
|
12474
11875
|
if (run.status === "revoked") fail("revoked", "Its publisher revoked this version");
|
|
12475
11876
|
if (run.status === "removed") fail("removed", `Removed by ${run.by[0].name}: ${run.by[0].reason}`);
|
|
12476
11877
|
const plan = planAppUpdate(installed, {
|
|
@@ -12479,15 +11880,20 @@ var Apps = class {
|
|
|
12479
11880
|
});
|
|
12480
11881
|
if (plan.action === "refuse") fail(plan.decision, "This update can no longer be installed");
|
|
12481
11882
|
const { pending: _p, ...without } = installed;
|
|
12482
|
-
const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from);
|
|
11883
|
+
const next = this.versionRecord(without, read.bundle, pending.bytes, pending.from, installed.store);
|
|
12483
11884
|
await this.putApp(next);
|
|
12484
11885
|
await this.removeBundle(installed.bytes, installed.digest);
|
|
12485
11886
|
return this.view(next, stores);
|
|
12486
11887
|
}
|
|
11888
|
+
/** An app's listing in the store its updates come from (`InstalledApp.store`), while that store is the person's. */
|
|
11889
|
+
pinnedListing(app, stores) {
|
|
11890
|
+
return app.store === void 0 ? void 0 : stores.find((s) => s.key === app.store)?.index?.apps.find((a) => a.ref === app.ref);
|
|
11891
|
+
}
|
|
12487
11892
|
/**
|
|
12488
11893
|
* The update check (WISP 1200 § Updates and rollback): only with an app installed; every store read again, then each
|
|
12489
|
-
* app's newer versions looked for in the stores' listings and its manifest's `sources
|
|
12490
|
-
*
|
|
11894
|
+
* app's newer versions looked for in the stores' listings and its manifest's `sources` (for an app installed from a
|
|
11895
|
+
* store, only in that store's listing, at the digest it lists), and its publisher's revocations read beside its
|
|
11896
|
+
* sources. An update that keeps or drops permissions installs by itself; one that adds
|
|
12491
11897
|
* some waits for the person. One check at a time.
|
|
12492
11898
|
*/
|
|
12493
11899
|
checkUpdates() {
|
|
@@ -12513,21 +11919,65 @@ var Apps = class {
|
|
|
12513
11919
|
if (!first) continue;
|
|
12514
11920
|
let app = first;
|
|
12515
11921
|
let outcome = "none";
|
|
11922
|
+
let wrote;
|
|
11923
|
+
if (app.store !== void 0 && !stores.some((s) => s.key === app.store)) {
|
|
11924
|
+
const { store: _s, ...unpinned } = app;
|
|
11925
|
+
app = unpinned;
|
|
11926
|
+
}
|
|
11927
|
+
if (app.store !== void 0 && app.pending && stores.find((s) => s.key === app.store)?.index && this.pinnedListing(app, stores)?.digest !== app.pending.digest) {
|
|
11928
|
+
await this.removeBundle(app.pending.bytes, app.pending.digest);
|
|
11929
|
+
const { pending: _p, ...rest } = app;
|
|
11930
|
+
app = rest;
|
|
11931
|
+
}
|
|
12516
11932
|
const highest = Math.max(app.sequence, app.pending?.sequence ?? 0);
|
|
12517
11933
|
const known = /* @__PURE__ */ new Set([app.digest, ...app.pending ? [app.pending.digest] : []]);
|
|
12518
|
-
const
|
|
11934
|
+
const pinned = app.store;
|
|
11935
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
11936
|
+
const newer = (v) => v.ref === app.ref && (v.sequence > highest || v.sequence >= app.sequence && !known.has(v.digest));
|
|
12519
11937
|
for (const s of stores) {
|
|
11938
|
+
if (pinned !== void 0 && s.key !== pinned) continue;
|
|
12520
11939
|
const listing = s.index?.apps.find((a) => a.ref === app.ref);
|
|
12521
|
-
if (listing
|
|
11940
|
+
if (!listing || !newer(listing)) continue;
|
|
11941
|
+
const seen = `${s.digest} ${listing.digest}`;
|
|
11942
|
+
for (const url of listingUrls(listing.urls)) {
|
|
11943
|
+
const missed = `${s.key} ${app.ref} ${url}`;
|
|
11944
|
+
if (candidates.has(url) || s.digest !== void 0 && this.listingMisses.get(missed) === seen) continue;
|
|
11945
|
+
candidates.set(url, {
|
|
11946
|
+
...pinned !== void 0 && { digest: listing.digest },
|
|
11947
|
+
peek: true,
|
|
11948
|
+
...s.digest !== void 0 && { miss: [missed, seen] }
|
|
11949
|
+
});
|
|
11950
|
+
}
|
|
12522
11951
|
}
|
|
12523
|
-
for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl))
|
|
12524
|
-
|
|
12525
|
-
|
|
12526
|
-
peek
|
|
12527
|
-
|
|
12528
|
-
|
|
12529
|
-
|
|
12530
|
-
|
|
11952
|
+
if (pinned === void 0) for (const url of (app.manifest.sources ?? []).filter(isAppFetchUrl)) {
|
|
11953
|
+
if (candidates.has(url)) continue;
|
|
11954
|
+
try {
|
|
11955
|
+
const peek = peekAppManifest(await this.host.fetch(url, {
|
|
11956
|
+
maxBytes: PEEK_BYTES,
|
|
11957
|
+
peek: true
|
|
11958
|
+
}));
|
|
11959
|
+
if (peek && newer(peek)) candidates.set(url, {});
|
|
11960
|
+
} catch {}
|
|
11961
|
+
}
|
|
11962
|
+
const revokeRead = /* @__PURE__ */ new Set();
|
|
11963
|
+
const before = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
|
|
11964
|
+
app = withRevocations(app, before ?? []);
|
|
11965
|
+
for (const [url, want] of candidates) {
|
|
11966
|
+
if (want.peek) {
|
|
11967
|
+
let peek;
|
|
11968
|
+
try {
|
|
11969
|
+
peek = peekAppManifest(await this.host.fetch(url, {
|
|
11970
|
+
maxBytes: PEEK_BYTES,
|
|
11971
|
+
peek: true
|
|
11972
|
+
}));
|
|
11973
|
+
} catch {
|
|
11974
|
+
continue;
|
|
11975
|
+
}
|
|
11976
|
+
if (!peek || !newer(peek) || want.digest !== void 0 && peek.digest !== want.digest) {
|
|
11977
|
+
if (want.miss) this.listingMisses.set(...want.miss);
|
|
11978
|
+
continue;
|
|
11979
|
+
}
|
|
11980
|
+
}
|
|
12531
11981
|
let fetched;
|
|
12532
11982
|
try {
|
|
12533
11983
|
fetched = await this.fetchBundle(url);
|
|
@@ -12537,6 +11987,7 @@ var Apps = class {
|
|
|
12537
11987
|
const { bundle, bytes } = fetched;
|
|
12538
11988
|
const version = versionOf(bundle.manifest, bundle.digest);
|
|
12539
11989
|
if (version.ref !== app.ref || known.has(version.digest)) continue;
|
|
11990
|
+
if (want.digest !== void 0 && version.digest !== want.digest) continue;
|
|
12540
11991
|
if (app.pending && version.sequence === app.pending.sequence) {
|
|
12541
11992
|
app = {
|
|
12542
11993
|
...app,
|
|
@@ -12567,17 +12018,21 @@ var Apps = class {
|
|
|
12567
12018
|
}
|
|
12568
12019
|
continue;
|
|
12569
12020
|
}
|
|
12570
|
-
if (this.runStatus(version, stores, app.revocations).status !== "ok") continue;
|
|
12021
|
+
if (this.runStatus(version, this.storeViews(stores), app.revocations).status !== "ok") continue;
|
|
12571
12022
|
if (app.pending && version.sequence < app.pending.sequence) continue;
|
|
12572
12023
|
const kind = await this.writeBundle(version.digest, bytes);
|
|
12024
|
+
wrote = {
|
|
12025
|
+
bytes: kind,
|
|
12026
|
+
digest: version.digest
|
|
12027
|
+
};
|
|
12573
12028
|
if (plan.action === "install") {
|
|
12574
12029
|
const old = app;
|
|
12575
|
-
|
|
12576
|
-
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
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);
|
|
12581
12036
|
await this.removeBundle(old.bytes, old.digest);
|
|
12582
12037
|
outcome = "updated";
|
|
12583
12038
|
} else {
|
|
@@ -12598,16 +12053,24 @@ var Apps = class {
|
|
|
12598
12053
|
}
|
|
12599
12054
|
break;
|
|
12600
12055
|
}
|
|
12601
|
-
const
|
|
12602
|
-
const
|
|
12603
|
-
|
|
12604
|
-
|
|
12056
|
+
const after = await this.readRevocations(app.ref, [app.from, ...app.manifest.sources ?? []], revokeRead);
|
|
12057
|
+
const revocations = before || after ? [...before ?? [], ...after ?? []] : null;
|
|
12058
|
+
const versions = [app, ...app.pending ? [versionOf(app.pending.manifest, app.pending.digest)] : []];
|
|
12059
|
+
const stays = (app.revocations ?? []).filter((r) => !revocations || versions.some((v) => appRevocationFor(v, [r])));
|
|
12060
|
+
const merged = new Map([...stays, ...revocations ?? []].map((r) => [canonicalJson(r), r]));
|
|
12061
|
+
if ((await this.app(app.ref))?.store === void 0 && app.store !== void 0) {
|
|
12062
|
+
const { store: _s, ...unpinned } = app;
|
|
12063
|
+
app = unpinned;
|
|
12064
|
+
}
|
|
12605
12065
|
app = {
|
|
12606
12066
|
...app,
|
|
12607
12067
|
...merged.size && { revocations: [...merged.values()] },
|
|
12608
12068
|
checkedAt: this.now()
|
|
12609
12069
|
};
|
|
12610
|
-
await this.
|
|
12070
|
+
if (!await this.putAppIfInstalled(app, first.installedAt)) {
|
|
12071
|
+
if (wrote) await this.removeUnusedBundle(app.ref, wrote.bytes, wrote.digest);
|
|
12072
|
+
continue;
|
|
12073
|
+
}
|
|
12611
12074
|
out.push({
|
|
12612
12075
|
ref: app.ref,
|
|
12613
12076
|
outcome,
|
|
@@ -12620,15 +12083,19 @@ var Apps = class {
|
|
|
12620
12083
|
* Fetches an installed app's bundle again when it is not on this device (a profile restored from a backup, which
|
|
12621
12084
|
* carries the installed list but no bundle: WISP 1200 § Where installed apps live). Only the installed digest is
|
|
12622
12085
|
* taken, checked whole: from the URL it was installed from, the listings of the person's stores that name that digest,
|
|
12623
|
-
* then its `sources
|
|
12086
|
+
* then its `sources` (for an app installed from a store: only that store's listing, never its sources). Nothing
|
|
12087
|
+
* answers: it stays `needs-files`, and it never runs unchecked.
|
|
12624
12088
|
*/
|
|
12625
12089
|
async fetchFiles({ ref }) {
|
|
12626
12090
|
const app = await this.installedOrFail(ref);
|
|
12627
12091
|
const stores = await this.stores();
|
|
12628
12092
|
if (await this.hasBundle(app.bytes, app.digest)) return this.runCheckOf(app, stores);
|
|
12629
12093
|
const urls = [app.from];
|
|
12630
|
-
for (const s of stores)
|
|
12631
|
-
|
|
12094
|
+
for (const s of stores) {
|
|
12095
|
+
if (app.store !== void 0 && s.key !== app.store) continue;
|
|
12096
|
+
for (const listing of s.index?.apps ?? []) if (listing.ref === app.ref && listing.digest === app.digest) urls.push(...listingUrls(listing.urls));
|
|
12097
|
+
}
|
|
12098
|
+
if (app.store === void 0) urls.push(...app.manifest.sources ?? []);
|
|
12632
12099
|
for (const url of new Set(urls.filter(isAppFetchUrl))) {
|
|
12633
12100
|
let fetched;
|
|
12634
12101
|
try {
|
|
@@ -12656,7 +12123,7 @@ var Apps = class {
|
|
|
12656
12123
|
}
|
|
12657
12124
|
async runCheckOf(app, stores) {
|
|
12658
12125
|
if (!await this.hasBundle(app.bytes, app.digest)) return { status: "needs-files" };
|
|
12659
|
-
return this.runStatus(app, stores, app.revocations);
|
|
12126
|
+
return this.runStatus(app, this.storeViews(stores), app.revocations);
|
|
12660
12127
|
}
|
|
12661
12128
|
/** Before an app runs (WISP 1200 § Takedowns): revoked stops it, removed by a store of the person's warns. No request. */
|
|
12662
12129
|
async runCheck({ ref }) {
|
|
@@ -12692,6 +12159,9 @@ var Apps = class {
|
|
|
12692
12159
|
/** A file of the installed bundle, for the broker's `file` (`ghostly.file(path)`). */
|
|
12693
12160
|
async file({ ref, path }) {
|
|
12694
12161
|
const app = path === "icon.png" ? await this.installedOrFail(ref) : await this.runningOrFail(ref);
|
|
12162
|
+
const icon = path === "icon.png" ? this.icons.get(app.digest) : void 0;
|
|
12163
|
+
if (icon) return icon.slice();
|
|
12164
|
+
if (icon === null) fail("no-file", "No such file in this app");
|
|
12695
12165
|
const bundle = await this.verifiedBundle(app);
|
|
12696
12166
|
if (typeof path !== "string" || !bundle.files.has(path)) fail("no-file", "No such file in this app");
|
|
12697
12167
|
return bundle.files.get(path).slice();
|
|
@@ -12808,7 +12278,12 @@ var Apps = class {
|
|
|
12808
12278
|
name
|
|
12809
12279
|
])))?.size ?? 0) + size;
|
|
12810
12280
|
if (next > APP_STORAGE_LIMITS.scopeBytes) fail("full", "This app's storage in this chat is full (5 MiB)");
|
|
12811
|
-
|
|
12281
|
+
const writing = await store(STORES$1.appStorage, "readwrite");
|
|
12282
|
+
if (where !== "alone" && !this.host.isChat(where)) {
|
|
12283
|
+
this.usage.delete(`${app.ref}\0${where}`);
|
|
12284
|
+
fail("bad-scope", "Not a chat of this profile");
|
|
12285
|
+
}
|
|
12286
|
+
await wrap(writing.put({
|
|
12812
12287
|
ref: app.ref,
|
|
12813
12288
|
scope: where,
|
|
12814
12289
|
key: name,
|
|
@@ -12853,7 +12328,12 @@ var Apps = class {
|
|
|
12853
12328
|
scope: row.scope,
|
|
12854
12329
|
entries: {}
|
|
12855
12330
|
};
|
|
12856
|
-
out.entries
|
|
12331
|
+
Object.defineProperty(out.entries, row.key, {
|
|
12332
|
+
value: JSON.parse(row.value),
|
|
12333
|
+
enumerable: true,
|
|
12334
|
+
writable: true,
|
|
12335
|
+
configurable: true
|
|
12336
|
+
});
|
|
12857
12337
|
scopes.set(row.scope, out);
|
|
12858
12338
|
}
|
|
12859
12339
|
return [...scopes.values()];
|
|
@@ -13179,7 +12659,26 @@ function parseDeviceRecord(value) {
|
|
|
13179
12659
|
if (!o || typeof o !== "object" || !bytes(o.d) || !bytes(o.tombstone) || !bytes(o.packet) || !(o.at === null || count(o.at, Number.MAX_SAFE_INTEGER))) return bad("ownSet");
|
|
13180
12660
|
if (!Array.isArray(o.sources) || o.sources.length > 16 || !o.sources.every(text)) return bad("ownSet");
|
|
13181
12661
|
}
|
|
13182
|
-
return value;
|
|
12662
|
+
return cleanNames(value);
|
|
12663
|
+
}
|
|
12664
|
+
/**
|
|
12665
|
+
* The record with every device name as `turnName` makes one: a name is another device's own word, and a build that did
|
|
12666
|
+
* not clean names may have stored it. The same record when there is nothing to clean.
|
|
12667
|
+
*/
|
|
12668
|
+
function cleanNames(record) {
|
|
12669
|
+
const dirty = (entry) => !!entry && turnName(entry.name) !== entry.name;
|
|
12670
|
+
if (!record.deviceSet.some(dirty) && !record.unfinishedGrants?.some(dirty)) return record;
|
|
12671
|
+
return {
|
|
12672
|
+
...record,
|
|
12673
|
+
deviceSet: record.deviceSet.map((slot) => slot && {
|
|
12674
|
+
...slot,
|
|
12675
|
+
name: turnName(slot.name)
|
|
12676
|
+
}),
|
|
12677
|
+
...record.unfinishedGrants ? { unfinishedGrants: record.unfinishedGrants.map((grant) => ({
|
|
12678
|
+
...grant,
|
|
12679
|
+
name: turnName(grant.name)
|
|
12680
|
+
})) } : {}
|
|
12681
|
+
};
|
|
13183
12682
|
}
|
|
13184
12683
|
function checked(record) {
|
|
13185
12684
|
return parseDeviceRecord(record);
|
|
@@ -14964,6 +14463,8 @@ async function openTurnKeeper(profile, network, options = {}) {
|
|
|
14964
14463
|
signer: (bytes) => identity.key.sign(bytes)
|
|
14965
14464
|
});
|
|
14966
14465
|
}
|
|
14466
|
+
/** A joiner with no session yet puts its packet out again this often: another device on the same code may have replaced it. */
|
|
14467
|
+
var ENROLL_REPUT_MS = 5e3;
|
|
14967
14468
|
/** How long, and how often, the joiner reads the turn record for the record that lists it. */
|
|
14968
14469
|
var ENROLL_FINISH = {
|
|
14969
14470
|
rounds: 20,
|
|
@@ -15674,6 +15175,11 @@ function ghostLinkEnrollChannel(options) {
|
|
|
15674
15175
|
};
|
|
15675
15176
|
let link = null;
|
|
15676
15177
|
const rtc = !!options.createPeerConnection;
|
|
15178
|
+
const intervals = options.pollIntervals ?? DHT_POLL_INTERVALS;
|
|
15179
|
+
let offered = false;
|
|
15180
|
+
const reput = pinned && rtc ? setInterval(() => {
|
|
15181
|
+
if (!opened) link?.republish();
|
|
15182
|
+
}, ENROLL_REPUT_MS) : null;
|
|
15677
15183
|
const tryOpen = () => {
|
|
15678
15184
|
if (opened || !link?.supportsDevice("enroll/1")) return;
|
|
15679
15185
|
const hash = link.sessionTranscriptHash, key = credentials.peerKey;
|
|
@@ -15696,7 +15202,10 @@ function ghostLinkEnrollChannel(options) {
|
|
|
15696
15202
|
native: { automatic: true },
|
|
15697
15203
|
packetTransports: true,
|
|
15698
15204
|
transport: options.transport,
|
|
15699
|
-
pollIntervals:
|
|
15205
|
+
pollIntervals: {
|
|
15206
|
+
...intervals,
|
|
15207
|
+
connected: intervals.active
|
|
15208
|
+
},
|
|
15700
15209
|
autoConnect: true,
|
|
15701
15210
|
createPeerConnection: () => {
|
|
15702
15211
|
if (!options.createPeerConnection) throw new ReferenceError("RTCPeerConnection is not defined");
|
|
@@ -15728,7 +15237,11 @@ function ghostLinkEnrollChannel(options) {
|
|
|
15728
15237
|
},
|
|
15729
15238
|
onPeerKeyRefused: () => events.onPeerRefused(),
|
|
15730
15239
|
onPresence: (presence) => {
|
|
15731
|
-
if (presence.online)
|
|
15240
|
+
if (!presence.online) return;
|
|
15241
|
+
events.onPeerSeen?.();
|
|
15242
|
+
if (!pinned || !rtc || offered || opened) return;
|
|
15243
|
+
offered = true;
|
|
15244
|
+
link?.connect().catch(() => {});
|
|
15732
15245
|
},
|
|
15733
15246
|
onPacketTransports: (transports) => {
|
|
15734
15247
|
if (!transports.includes("webrtc/1")) startNative();
|
|
@@ -15760,6 +15273,7 @@ function ghostLinkEnrollChannel(options) {
|
|
|
15760
15273
|
stop: async () => {
|
|
15761
15274
|
const running = link;
|
|
15762
15275
|
link = null;
|
|
15276
|
+
if (reput) clearInterval(reput);
|
|
15763
15277
|
await running?.stop().catch(() => {});
|
|
15764
15278
|
}
|
|
15765
15279
|
};
|
|
@@ -18718,7 +18232,7 @@ async function room() {
|
|
|
18718
18232
|
async function handoffSelf(profileHost) {
|
|
18719
18233
|
return {
|
|
18720
18234
|
app: profileHost.app.replace(/[^0-9A-Za-z.+_-]/g, "").slice(0, 40) || "0",
|
|
18721
|
-
db:
|
|
18235
|
+
db: 15,
|
|
18722
18236
|
pins: { ...WALLET_SDK_PINS },
|
|
18723
18237
|
kind: profileHost.kind,
|
|
18724
18238
|
room: await room(),
|
|
@@ -20761,6 +20275,7 @@ var Outbox = class {
|
|
|
20761
20275
|
return this.turn(async () => {});
|
|
20762
20276
|
}
|
|
20763
20277
|
async requeueUnconfirmed() {
|
|
20278
|
+
if (!this.receipts.size) return;
|
|
20764
20279
|
const messages = await this.store.read();
|
|
20765
20280
|
for (const id of [...this.receipts.keys()]) {
|
|
20766
20281
|
if (messages.find((m) => m.id === id)?.via === "pkarr") {
|
|
@@ -20880,6 +20395,24 @@ var days = (ms) => {
|
|
|
20880
20395
|
};
|
|
20881
20396
|
//#endregion
|
|
20882
20397
|
//#region ../browser/src/engine/edits.ts
|
|
20398
|
+
/**
|
|
20399
|
+
* My edits on their way to the contact (WISP 400 § Edits), one chat's worth. What goes is always the latest version of
|
|
20400
|
+
* a message, read from its row: an edit made while an earlier one waits replaces it, so a bot rewriting a status never
|
|
20401
|
+
* queues old versions. It keeps the outbox's resend policy (the window, the attempts, the backoff) in a queue of its
|
|
20402
|
+
* own, since the outbox sends rows and an edited row was delivered long ago.
|
|
20403
|
+
*
|
|
20404
|
+
* An edit goes on a live session where both sides offer edit/1, or on the DHT floor to a contact whose app takes edits
|
|
20405
|
+
* there, at most `EDIT_SEND_LIMIT` per window; the rest wait for the window, a confirmation, or the next session. A
|
|
20406
|
+
* message that never left yet (`waiting`) carries its latest text when it goes, and its edit follows it.
|
|
20407
|
+
*/
|
|
20408
|
+
/**
|
|
20409
|
+
* A card that goes again live (`restore`, WISP 405 § Cards that went as text) goes at most once per this long: one a
|
|
20410
|
+
* reconnect owes many go in a trickle, not a burst. Two edit slots of the pace (10 in 10 s), so cards going again
|
|
20411
|
+
* take at most half of it and the author's live updates keep the rest; the CLI paces a card's own updates alike (2.5 s).
|
|
20412
|
+
*/
|
|
20413
|
+
var CARD_RESTORE_GAP_MS = 2 * EDIT_RATE_WINDOW_MS / 10;
|
|
20414
|
+
/** What makes a card the same card across messages: a new message may reuse a card's id, and the newest stands for it. */
|
|
20415
|
+
var cardKey = (message) => message.card && `${message.card.kind}\n${message.card.id}`;
|
|
20883
20416
|
var EditQueue = class {
|
|
20884
20417
|
deps;
|
|
20885
20418
|
pace;
|
|
@@ -20887,6 +20420,8 @@ var EditQueue = class {
|
|
|
20887
20420
|
now;
|
|
20888
20421
|
/** Per message: the edit number last sent, when, and how many times that number went. */
|
|
20889
20422
|
sent = /* @__PURE__ */ new Map();
|
|
20423
|
+
/** When a card that goes again last went (`CARD_RESTORE_GAP_MS`). */
|
|
20424
|
+
restoredAt = -Infinity;
|
|
20890
20425
|
timer;
|
|
20891
20426
|
flushing = null;
|
|
20892
20427
|
again = false;
|
|
@@ -20953,7 +20488,14 @@ var EditQueue = class {
|
|
|
20953
20488
|
if (this.stopped) return;
|
|
20954
20489
|
const now = this.now();
|
|
20955
20490
|
const said = (m) => this.sent.get(m.id)?.seq === m.edit.seq ? 1 : 0;
|
|
20956
|
-
const
|
|
20491
|
+
const rows = await this.deps.read();
|
|
20492
|
+
const newest = /* @__PURE__ */ new Map();
|
|
20493
|
+
for (const m of rows) {
|
|
20494
|
+
const key = m.sender === "me" && cardKey(m);
|
|
20495
|
+
if (key && m.timestamp > (newest.get(key) ?? -Infinity)) newest.set(key, m.timestamp);
|
|
20496
|
+
}
|
|
20497
|
+
const due = rows.filter((m) => m.sender === "me" && m.edit?.pending && m.wireId).sort((a, b) => said(a) - said(b) || a.edit.at - b.edit.at);
|
|
20498
|
+
const gap = this.deps.restoreGapMs ?? CARD_RESTORE_GAP_MS;
|
|
20957
20499
|
let next = Infinity;
|
|
20958
20500
|
for (const message of due) {
|
|
20959
20501
|
const edit = message.edit;
|
|
@@ -20962,6 +20504,11 @@ var EditQueue = class {
|
|
|
20962
20504
|
await this.deps.settle(message.id, edit.seq);
|
|
20963
20505
|
continue;
|
|
20964
20506
|
}
|
|
20507
|
+
if (edit.restore && message.timestamp < (newest.get(cardKey(message) || "") ?? -Infinity)) {
|
|
20508
|
+
this.sent.delete(message.id);
|
|
20509
|
+
await this.deps.settle(message.id, edit.seq);
|
|
20510
|
+
continue;
|
|
20511
|
+
}
|
|
20965
20512
|
if (message.delivery === "waiting") continue;
|
|
20966
20513
|
if (!this.deps.ready()) return;
|
|
20967
20514
|
const last = this.sent.get(message.id);
|
|
@@ -20974,6 +20521,10 @@ var EditQueue = class {
|
|
|
20974
20521
|
continue;
|
|
20975
20522
|
}
|
|
20976
20523
|
}
|
|
20524
|
+
if (edit.restore && this.restoredAt + gap > now) {
|
|
20525
|
+
next = Math.min(next, this.restoredAt + gap);
|
|
20526
|
+
continue;
|
|
20527
|
+
}
|
|
20977
20528
|
const pace = this.pace.wait();
|
|
20978
20529
|
if (pace > 0) {
|
|
20979
20530
|
next = Math.min(next, now + pace);
|
|
@@ -20989,6 +20540,7 @@ var EditQueue = class {
|
|
|
20989
20540
|
...message.preview && { pv: message.preview },
|
|
20990
20541
|
...message.card && { sc: message.card }
|
|
20991
20542
|
}, message)) continue;
|
|
20543
|
+
if (edit.restore) this.restoredAt = now;
|
|
20992
20544
|
this.sent.set(message.id, {
|
|
20993
20545
|
seq: edit.seq,
|
|
20994
20546
|
at: now,
|
|
@@ -21598,18 +21150,35 @@ var db = {
|
|
|
21598
21150
|
},
|
|
21599
21151
|
async deleteLink(linkId) {
|
|
21600
21152
|
await wrap((await store(STORES$1.links, "readwrite")).delete(linkId));
|
|
21601
|
-
|
|
21602
|
-
const keys = await wrap(messages.index("byLink").getAllKeys(linkId));
|
|
21603
|
-
await Promise.all(keys.map((key) => wrap(messages.delete(key))));
|
|
21153
|
+
await wrap((await store(STORES$1.messages, "readwrite")).delete(IDBKeyRange.bound([linkId], [linkId, []])));
|
|
21604
21154
|
await fileStore.deleteForLink(linkId);
|
|
21605
21155
|
await removeFileBytes(`${linkId}-in-`);
|
|
21606
21156
|
await removeFileBytes(`${linkId}-out-`);
|
|
21607
21157
|
const appStorage = await store(STORES$1.appStorage, "readwrite");
|
|
21608
|
-
|
|
21609
|
-
|
|
21158
|
+
await new Promise((resolve, reject) => {
|
|
21159
|
+
appStorage.transaction.oncomplete = () => resolve();
|
|
21160
|
+
appStorage.transaction.onerror = appStorage.transaction.onabort = () => reject(appStorage.transaction.error ?? /* @__PURE__ */ new Error("App storage failed"));
|
|
21161
|
+
const request = appStorage.index(APP_STORAGE_CHAT_INDEX).openKeyCursor(IDBKeyRange.only(linkId));
|
|
21162
|
+
request.onsuccess = () => {
|
|
21163
|
+
const cursor = request.result;
|
|
21164
|
+
if (!cursor) return;
|
|
21165
|
+
const [ref] = cursor.primaryKey;
|
|
21166
|
+
appStorage.delete(IDBKeyRange.bound([ref, linkId], [
|
|
21167
|
+
ref,
|
|
21168
|
+
linkId,
|
|
21169
|
+
[]
|
|
21170
|
+
]));
|
|
21171
|
+
cursor.continue();
|
|
21172
|
+
};
|
|
21173
|
+
});
|
|
21610
21174
|
},
|
|
21175
|
+
/**
|
|
21176
|
+
* A chat's whole history, by time. Read by its key range (every key is `[linkId, id]`, ids are strings: from `[linkId]`
|
|
21177
|
+
* to `[linkId, []]`), the same rows in the same order as the `byLink` index has them, without looking each one up
|
|
21178
|
+
* again: in the CLI's store (fake-indexeddb) that lookup cost 4-9 times the read itself.
|
|
21179
|
+
*/
|
|
21611
21180
|
async getMessages(linkId) {
|
|
21612
|
-
return (await wrap((await store(STORES$1.messages, "readonly")).
|
|
21181
|
+
return (await wrap((await store(STORES$1.messages, "readonly")).getAll(IDBKeyRange.bound([linkId], [linkId, []])))).sort((a, b) => a.timestamp - b.timestamp);
|
|
21613
21182
|
},
|
|
21614
21183
|
/** Whether the chat has a message with this id. */
|
|
21615
21184
|
async hasMessage(linkId, id) {
|
|
@@ -21806,9 +21375,10 @@ function noteAfterChange(current, message) {
|
|
|
21806
21375
|
};
|
|
21807
21376
|
}
|
|
21808
21377
|
/**
|
|
21809
|
-
* My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`,
|
|
21810
|
-
*
|
|
21811
|
-
*
|
|
21378
|
+
* My reactions in a private group to say again to an edge that opened, in case it missed them: the newest `limit`,
|
|
21379
|
+
* newest first (what the member is likeliest to have missed lands before its pace runs out), of this membership only.
|
|
21380
|
+
* One made before I was removed and invited again went out under my old member key; said again now it would go under
|
|
21381
|
+
* the new one, and every member would show it twice.
|
|
21812
21382
|
*/
|
|
21813
21383
|
function groupReactionsToResend(messages, limit) {
|
|
21814
21384
|
let joinedAt = 0;
|
|
@@ -21820,7 +21390,7 @@ function groupReactionsToResend(messages, limit) {
|
|
|
21820
21390
|
e: r.e,
|
|
21821
21391
|
n: r.n
|
|
21822
21392
|
}] : [];
|
|
21823
|
-
}).sort((a, b) => b.n - a.n).slice(0, limit)
|
|
21393
|
+
}).sort((a, b) => b.n - a.n).slice(0, limit);
|
|
21824
21394
|
}
|
|
21825
21395
|
/** This side's highest number in a chat: the next one goes past it (and past what still waits to be confirmed). */
|
|
21826
21396
|
function lastMine(messages, pending = []) {
|
|
@@ -21831,7 +21401,7 @@ function lastMine(messages, pending = []) {
|
|
|
21831
21401
|
}
|
|
21832
21402
|
var Reactions = class {
|
|
21833
21403
|
host;
|
|
21834
|
-
/** Reactions to messages not here yet, per chat, until `REACTION_LIMITS.bufferMs` after they came. */
|
|
21404
|
+
/** Reactions to messages not here yet, per chat, until `REACTION_LIMITS.bufferMs` after they came (`buffer` per sender). */
|
|
21835
21405
|
waiting = /* @__PURE__ */ new Map();
|
|
21836
21406
|
/** One chat's reactions at a time: a burst for one message lands in order. */
|
|
21837
21407
|
queue = Promise.resolve();
|
|
@@ -21877,7 +21447,7 @@ var Reactions = class {
|
|
|
21877
21447
|
}
|
|
21878
21448
|
/**
|
|
21879
21449
|
* A reaction that came from `by` (`peer`, or a member's key), already authenticated as theirs. A message not here
|
|
21880
|
-
* yet keeps it for
|
|
21450
|
+
* yet keeps it for five minutes; past the room for that sender, it is dropped (and, on a 1:1 session, not confirmed).
|
|
21881
21451
|
*/
|
|
21882
21452
|
receive(chat, by, reaction) {
|
|
21883
21453
|
if (isGroup$1(chat)) {
|
|
@@ -21904,21 +21474,22 @@ var Reactions = class {
|
|
|
21904
21474
|
const now = this.now(), all = (this.waiting.get(message.linkId) ?? []).filter((w) => w.until > now);
|
|
21905
21475
|
const mine = all.filter((w) => w.reaction.id === id).sort((a, b) => a.reaction.n - b.reaction.n);
|
|
21906
21476
|
this.waiting.set(message.linkId, all.filter((w) => w.reaction.id !== id));
|
|
21907
|
-
for (const { by, reaction } of mine)
|
|
21908
|
-
const target = reactionTarget(await this.host.messages(message.linkId), id, isGroup$1(message.linkId));
|
|
21909
|
-
if (target && reactionIsNewer(target.reactions?.[by], reaction.n)) await this.write(message.linkId, target, by, reaction);
|
|
21910
|
-
}
|
|
21477
|
+
for (const { by, reaction } of mine) await this.write(message.linkId, message, by, reaction);
|
|
21911
21478
|
});
|
|
21912
21479
|
}
|
|
21913
21480
|
/**
|
|
21914
21481
|
* The row a received reaction names. Most often the row of that id (a group's), or of `peer_` or `me_` and that id (a
|
|
21915
|
-
* chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat
|
|
21482
|
+
* chat's): those are read alone. Any other (a file's, a payment's, one not here yet) is looked for in the whole chat;
|
|
21483
|
+
* in a group there is no other, since a row that takes a reaction goes by its own id there.
|
|
21916
21484
|
*/
|
|
21917
21485
|
async find(chat, id) {
|
|
21918
21486
|
const group = isGroup$1(chat);
|
|
21919
|
-
if (this.host.message)
|
|
21920
|
-
const
|
|
21921
|
-
|
|
21487
|
+
if (this.host.message) {
|
|
21488
|
+
for (const rowId of group ? [id] : [`peer_${id}`, `me_${id}`]) {
|
|
21489
|
+
const row = await this.host.message(chat, rowId);
|
|
21490
|
+
if (row && replyRef(row, group) === id) return row;
|
|
21491
|
+
}
|
|
21492
|
+
if (group) return void 0;
|
|
21922
21493
|
}
|
|
21923
21494
|
return reactionTarget(await this.host.messages(chat), id, group);
|
|
21924
21495
|
}
|
|
@@ -21933,7 +21504,7 @@ var Reactions = class {
|
|
|
21933
21504
|
if (same && same.reaction.n >= reaction.n) return "stale";
|
|
21934
21505
|
const kept = list.filter((w) => w !== same);
|
|
21935
21506
|
this.waiting.set(chat, kept);
|
|
21936
|
-
if (kept.length >= REACTION_LIMITS.buffer) return "dropped";
|
|
21507
|
+
if (kept.filter((w) => w.by === by).length >= REACTION_LIMITS.buffer) return "dropped";
|
|
21937
21508
|
kept.push({
|
|
21938
21509
|
by,
|
|
21939
21510
|
reaction,
|
|
@@ -22099,11 +21670,14 @@ var HubClocks = class {
|
|
|
22099
21670
|
};
|
|
22100
21671
|
//#endregion
|
|
22101
21672
|
//#region ../browser/src/engine/community.ts
|
|
22102
|
-
/** The line a change of a group's picture leaves in its history (both profiles). */
|
|
22103
|
-
var pictureText = (name, set) => `${name} ${set ? "changed" : "removed"} the group's picture`;
|
|
21673
|
+
/** The line a change of a group's picture leaves in its history (both profiles); without a name, nobody is credited. */
|
|
21674
|
+
var pictureText = (name, set) => name === void 0 ? `The group's picture was ${set ? "changed" : "removed"}` : `${name} ${set ? "changed" : "removed"} the group's picture`;
|
|
22104
21675
|
/** The line a new name leaves (both profiles): `group` is the name the group has now. */
|
|
22105
|
-
var renameText = (name, group) => `${name} renamed the group to “${group}”`;
|
|
22106
|
-
/**
|
|
21676
|
+
var renameText = (name, group) => name === void 0 ? `The group is now called “${group}”` : `${name} renamed the group to “${group}”`;
|
|
21677
|
+
/**
|
|
21678
|
+
* The lines a statement that changed the group's name or picture leaves in its history, in that order. No `name` when
|
|
21679
|
+
* the core cannot say who made the change (a new admin signed again what the last one set).
|
|
21680
|
+
*/
|
|
22107
21681
|
function metaLines(name, change, group) {
|
|
22108
21682
|
return [..."name" in change ? [{
|
|
22109
21683
|
event: "renamed",
|
|
@@ -22493,7 +22067,8 @@ var Communities = class {
|
|
|
22493
22067
|
await this.store.putGroup(group);
|
|
22494
22068
|
this.host.emit();
|
|
22495
22069
|
}
|
|
22496
|
-
|
|
22070
|
+
/** Whether a message of mine could go to the group now: the refusal `send` would answer for not being in it, or null. */
|
|
22071
|
+
sendCheck(groupId) {
|
|
22497
22072
|
const live = this.live.get(groupId);
|
|
22498
22073
|
if (!live) return {
|
|
22499
22074
|
error: "You are not in this group yet",
|
|
@@ -22503,6 +22078,12 @@ var Communities = class {
|
|
|
22503
22078
|
error: live.session.state.statusReason ?? "You are not in this group",
|
|
22504
22079
|
refused: true
|
|
22505
22080
|
};
|
|
22081
|
+
return { error: null };
|
|
22082
|
+
}
|
|
22083
|
+
async send(groupId, text, mentions = [], reply, forwarded, card, file) {
|
|
22084
|
+
const check = this.sendCheck(groupId);
|
|
22085
|
+
if (check.error) return check;
|
|
22086
|
+
const live = this.live.get(groupId);
|
|
22506
22087
|
const now = this.now();
|
|
22507
22088
|
const result = card || file ? await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded, card, file) : await live.session.sendText(text, this.host.myNick?.(), now, mentions, reply, forwarded);
|
|
22508
22089
|
return "error" in result ? { error: result.error } : {
|
|
@@ -22632,6 +22213,7 @@ var Communities = class {
|
|
|
22632
22213
|
this.knockRead.delete(groupId);
|
|
22633
22214
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
22634
22215
|
await this.store.deleteGroup(groupId);
|
|
22216
|
+
await this.files?.drop(groupId);
|
|
22635
22217
|
this.host.historyGone?.(groupId);
|
|
22636
22218
|
this.host.emit();
|
|
22637
22219
|
}
|
|
@@ -23385,6 +22967,16 @@ var Communities = class {
|
|
|
23385
22967
|
}
|
|
23386
22968
|
}
|
|
23387
22969
|
/**
|
|
22970
|
+
* Let in: my knock leaves the record I knocked in. It stayed there until it aged out, and someone opening the link in
|
|
22971
|
+
* the next minute read it as a person still waiting to be let in. Once, in the background; if it fails, it ages out.
|
|
22972
|
+
*/
|
|
22973
|
+
async unknock(link, n, seedB64) {
|
|
22974
|
+
const me = identityFromSeedB64(seedB64).pubKeyZ32, record = knockRecord(link, n);
|
|
22975
|
+
const knocks = readKnocks(record, await this.host.resolve(knockIdentity(record).pubKeyZ32, true) ?? []);
|
|
22976
|
+
if (!knocks.some((k) => k.key === me)) return;
|
|
22977
|
+
await this.host.publish(knockIdentity(record), knockRecords(record, knocks.filter((k) => k.key !== me)), true);
|
|
22978
|
+
}
|
|
22979
|
+
/**
|
|
23388
22980
|
* The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
|
|
23389
22981
|
* link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
|
|
23390
22982
|
* its next tick (it stops being a hub and a door there), and knocked for good with nothing for a member to answer on.
|
|
@@ -23507,6 +23099,10 @@ var Communities = class {
|
|
|
23507
23099
|
await this.store.putGroup(member);
|
|
23508
23100
|
if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
|
|
23509
23101
|
else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
|
|
23102
|
+
if (this.knocked.has(g)) this.unknock({
|
|
23103
|
+
g,
|
|
23104
|
+
host: joining.host
|
|
23105
|
+
}, this.knockAt.get(g) ?? KNOCK_BELL, joining.seedB64).catch(() => {});
|
|
23510
23106
|
this.lastKnock.delete(g);
|
|
23511
23107
|
this.knocked.delete(g);
|
|
23512
23108
|
this.knockAt.delete(g);
|
|
@@ -23654,7 +23250,7 @@ var Communities = class {
|
|
|
23654
23250
|
this.membershipChanged(id);
|
|
23655
23251
|
},
|
|
23656
23252
|
metaChanged: (by, change, at) => {
|
|
23657
|
-
const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
|
|
23253
|
+
const name = by === void 0 ? void 0 : by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
|
|
23658
23254
|
const when = receivedTimestamp(at, this.now());
|
|
23659
23255
|
(async () => {
|
|
23660
23256
|
for (const line of metaLines(name, change, session.name)) await this.event(id, line.event, line.text, when, session.epoch, by);
|
|
@@ -23851,7 +23447,8 @@ var MeshHubs = class {
|
|
|
23851
23447
|
primed: false,
|
|
23852
23448
|
gone: /* @__PURE__ */ new Map(),
|
|
23853
23449
|
reactions: /* @__PURE__ */ new Map(),
|
|
23854
|
-
need: 0
|
|
23450
|
+
need: 0,
|
|
23451
|
+
departed: /* @__PURE__ */ new Set()
|
|
23855
23452
|
});
|
|
23856
23453
|
}
|
|
23857
23454
|
return live;
|
|
@@ -23903,10 +23500,36 @@ var MeshHubs = class {
|
|
|
23903
23500
|
const live = this.live.get(groupId);
|
|
23904
23501
|
if (!live || !this.large(session)) return [];
|
|
23905
23502
|
const policy = session.hubPolicy, inRoster = (key) => rosterHas(session.roster, key);
|
|
23906
|
-
const out = new Set(
|
|
23907
|
-
for (const [key, at] of live.seenHubs) if (now - at <= this.timings.graceMs && key !== session.myKey && inRoster(key) && !policy.no.includes(key)) out.add(key);
|
|
23503
|
+
const out = new Set(this.listed(live, session, now).map((h) => h.key));
|
|
23504
|
+
for (const [key, at] of live.seenHubs) if (now - at <= this.timings.graceMs && key !== session.myKey && inRoster(key) && !policy.no.includes(key) && !live.departed.has(key)) out.add(key);
|
|
23908
23505
|
return [...out];
|
|
23909
23506
|
}
|
|
23507
|
+
/** The beacon's hubs a member counts on (`meshHubs`), but those that said they leave. */
|
|
23508
|
+
listed(live, session, now) {
|
|
23509
|
+
return meshHubs(live.beacon, session.myKey, session.hubPolicy, (key) => rosterHas(session.roster, key), now).filter((h) => !live.departed.has(h.key));
|
|
23510
|
+
}
|
|
23511
|
+
/**
|
|
23512
|
+
* A member said on its own edge that it leaves: if it was a hub, it is none from now on, rather than until the beacon
|
|
23513
|
+
* forgets it and its grace runs out (the commit that takes it out may have no other way here). True when a hub went.
|
|
23514
|
+
*/
|
|
23515
|
+
departs(groupId, key) {
|
|
23516
|
+
const live = this.live.get(groupId);
|
|
23517
|
+
if (!live || live.departed.has(key)) return false;
|
|
23518
|
+
live.departed.add(key);
|
|
23519
|
+
const was = live.seenHubs.delete(key) || live.myHubs.includes(key) || live.beacon.some((h) => h.key === key);
|
|
23520
|
+
live.reach.delete(key);
|
|
23521
|
+
live.hubsUp.delete(key);
|
|
23522
|
+
live.myHubs = live.myHubs.filter((k) => k !== key);
|
|
23523
|
+
return was;
|
|
23524
|
+
}
|
|
23525
|
+
/** As a hub that leaves the group: out of the beacon on my way, while the session can still write it. */
|
|
23526
|
+
async stepDown(groupId, session, now) {
|
|
23527
|
+
const live = this.live.get(groupId);
|
|
23528
|
+
if (!live?.hub) return;
|
|
23529
|
+
live.hub = false;
|
|
23530
|
+
live.members.clear();
|
|
23531
|
+
await this.publish(groupId, session, live, now, false).catch(() => {});
|
|
23532
|
+
}
|
|
23910
23533
|
/** The group runs on hubs: it is large, and I am a hub or know of one. Else it is the full mesh. */
|
|
23911
23534
|
active(groupId, session, now) {
|
|
23912
23535
|
return this.large(session) && (this.isHub(groupId) || this.hubs(groupId, session, now).length > 0);
|
|
@@ -24003,7 +23626,7 @@ var MeshHubs = class {
|
|
|
24003
23626
|
const may = mayBeHub(me, policy, inRoster, this.staysOnline);
|
|
24004
23627
|
const readEvery = live.hub || may ? this.timings.beaconEveryMs : this.timings.beaconReadMs;
|
|
24005
23628
|
if (!live.lastRead || now - live.lastRead >= readEvery) await this.read(groupId, session, live, now);
|
|
24006
|
-
const others =
|
|
23629
|
+
const others = this.listed(live, session, now);
|
|
24007
23630
|
const fits = this.fits(groupId, session);
|
|
24008
23631
|
if (live.hub) live.need = session.others.length;
|
|
24009
23632
|
if (!live.hub && may && fits && (policy.pin.includes(me) || others.length < MESH_HUBS.maxHubs)) {
|
|
@@ -24044,8 +23667,9 @@ var MeshHubs = class {
|
|
|
24044
23667
|
this.noteHubs(live, session, now);
|
|
24045
23668
|
}
|
|
24046
23669
|
noteHubs(live, session, now) {
|
|
24047
|
-
for (const h of
|
|
23670
|
+
for (const h of this.listed(live, session, now)) live.seenHubs.set(h.key, now);
|
|
24048
23671
|
for (const [key, at] of live.seenHubs) if (now - at > this.timings.graceMs || !rosterHas(session.roster, key)) live.seenHubs.delete(key);
|
|
23672
|
+
for (const key of live.departed) if (!rosterHas(session.roster, key)) live.departed.delete(key);
|
|
24049
23673
|
}
|
|
24050
23674
|
/** My entry in the beacon (or none, stepping down), merged with the other fresh hubs I do not drop. */
|
|
24051
23675
|
async publish(groupId, session, live, now, listed) {
|
|
@@ -24130,7 +23754,7 @@ var MeshHubs = class {
|
|
|
24130
23754
|
live.hubWaits.delete(key);
|
|
24131
23755
|
}
|
|
24132
23756
|
}
|
|
24133
|
-
const fresh =
|
|
23757
|
+
const fresh = this.listed(live, session, now);
|
|
24134
23758
|
const candidates = [...fresh, ...[...hubs].filter((key) => !fresh.some((h) => h.key === key)).map((key) => ({
|
|
24135
23759
|
key,
|
|
24136
23760
|
ts: 0,
|
|
@@ -24246,7 +23870,7 @@ var MeshHubs = class {
|
|
|
24246
23870
|
if (!rosterHas(session.roster, from) || !Array.isArray(frame.k) || frame.k.length > session.roster.length) return false;
|
|
24247
23871
|
const live = this.get(groupId, now);
|
|
24248
23872
|
live.reach.set(from, new Set(frame.k.filter((key) => typeof key === "string" && rosterHas(session.roster, key))));
|
|
24249
|
-
if (!session.hubPolicy.no.includes(from) && from !== session.myKey) live.seenHubs.set(from, now);
|
|
23873
|
+
if (!session.hubPolicy.no.includes(from) && from !== session.myKey && !live.departed.has(from)) live.seenHubs.set(from, now);
|
|
24250
23874
|
if (!Array.isArray(frame.l) || frame.l.length > session.roster.length) return false;
|
|
24251
23875
|
const legacy = new Set(group.legacy ?? []);
|
|
24252
23876
|
let changed = false;
|
|
@@ -24726,6 +24350,22 @@ var ENTRY_RELEASE_MS = 2e3;
|
|
|
24726
24350
|
var REMOVED_LINGER_MS = 15e3;
|
|
24727
24351
|
/** How long the tombstone of a group I left waits for the admin to hear it. */
|
|
24728
24352
|
var LEFT_KEPT_MS = 6048e5;
|
|
24353
|
+
/**
|
|
24354
|
+
* Invitee side: an accept the admin has not answered with a welcome is said again this often while the inviter's chat
|
|
24355
|
+
* is up, and at once when it comes up, at most `ACCEPT_AGAINS` times each time it does (its app may have restarted).
|
|
24356
|
+
*/
|
|
24357
|
+
var ACCEPT_AGAIN_MS = 6e4;
|
|
24358
|
+
var ACCEPT_AGAINS = 5;
|
|
24359
|
+
/** Invitee side: the invitations one contact chat may have waiting here at a time. More are answered as declined. */
|
|
24360
|
+
var MAX_INVITATIONS_PER_CHAT = 4;
|
|
24361
|
+
/** What an invitation says of its group, kept as a claim within what a group can be: an epoch from 0, a roster of 1 to the most a group holds. */
|
|
24362
|
+
function invitationClaims(frame) {
|
|
24363
|
+
const e = Number.isSafeInteger(frame.e) ? frame.e : 0, n = Number.isSafeInteger(frame.n) ? frame.n : 1;
|
|
24364
|
+
return {
|
|
24365
|
+
e: Math.max(e, 0),
|
|
24366
|
+
n: Math.min(Math.max(n, 1), GROUP_MEMBER_CAP.max)
|
|
24367
|
+
};
|
|
24368
|
+
}
|
|
24729
24369
|
/** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
|
|
24730
24370
|
var FAREWELL_ANSWERS = 3;
|
|
24731
24371
|
/** Commits sent to a removed member that was behind when it left, at most. */
|
|
@@ -24787,8 +24427,10 @@ var Groups = class {
|
|
|
24787
24427
|
timings;
|
|
24788
24428
|
stored = /* @__PURE__ */ new Map();
|
|
24789
24429
|
sessions = /* @__PURE__ */ new Map();
|
|
24790
|
-
/** Contacts (chat ids) I invited to each group, until they answer
|
|
24430
|
+
/** Contacts (chat ids, kept in `StoredGroup.invited`) and entry sessions I invited to each group, until they answer. */
|
|
24791
24431
|
invited = /* @__PURE__ */ new Map();
|
|
24432
|
+
/** Invitee side: when my accept of each invitation was last said, how many times since the inviter's chat came up, and whether it was down. */
|
|
24433
|
+
acceptSaid = /* @__PURE__ */ new Map();
|
|
24792
24434
|
lastRoster = /* @__PURE__ */ new Map();
|
|
24793
24435
|
/** Each group's status and newest commit as its history last said them: a line per change of either, never one again. */
|
|
24794
24436
|
lastTold = /* @__PURE__ */ new Map();
|
|
@@ -24831,6 +24473,14 @@ var Groups = class {
|
|
|
24831
24473
|
*/
|
|
24832
24474
|
relayAsked = /* @__PURE__ */ new Map();
|
|
24833
24475
|
lastGossip = /* @__PURE__ */ new Map();
|
|
24476
|
+
/**
|
|
24477
|
+
* Per mesh group, each reachable member some of whose messages never reached me (`GroupSession.lacking`): what was
|
|
24478
|
+
* missing when it was asked for, and of whom since. Each connected member once until that changes, so messages nobody
|
|
24479
|
+
* holds any more are not asked for every gossip turn. `last`: the latest ask, its member and the numbers it named.
|
|
24480
|
+
*/
|
|
24481
|
+
gapsAsked = /* @__PURE__ */ new Map();
|
|
24482
|
+
/** Per mesh group, when `gapsAgain` last looked: every half `MESH_GAP_AGAIN_MS`, not every tick. */
|
|
24483
|
+
gapsLooked = /* @__PURE__ */ new Map();
|
|
24834
24484
|
/** Per mesh group: member key → since when its edge to me is down, and when someone last told me it is here. */
|
|
24835
24485
|
downSince = /* @__PURE__ */ new Map();
|
|
24836
24486
|
hereHeard = /* @__PURE__ */ new Map();
|
|
@@ -24899,12 +24549,14 @@ var Groups = class {
|
|
|
24899
24549
|
this.communities.files = this.files;
|
|
24900
24550
|
}
|
|
24901
24551
|
}
|
|
24902
|
-
|
|
24552
|
+
/** `asItIs`: a copy that must stay as it was stored (limited mode, WISP 06), so nothing left over is cleared from it. */
|
|
24553
|
+
async load(asItIs = false) {
|
|
24903
24554
|
const all = await this.store.getGroups();
|
|
24904
24555
|
await this.communities.load(all.filter((g) => g.community || g.joining));
|
|
24905
24556
|
for (const group of all.filter((g) => !g.community && !g.joining)) {
|
|
24906
24557
|
this.stored.set(group.id, group);
|
|
24907
24558
|
if (group.state) this.attach(group.state);
|
|
24559
|
+
if (group.invited) this.invited.set(group.id, new Set(Object.keys(group.invited)));
|
|
24908
24560
|
const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
|
|
24909
24561
|
const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
|
|
24910
24562
|
if (last) this.lastMessageAt.set(group.id, last.timestamp);
|
|
@@ -24916,6 +24568,7 @@ var Groups = class {
|
|
|
24916
24568
|
}
|
|
24917
24569
|
for (const id of this.sessions.keys()) this.reconcileEdges(id);
|
|
24918
24570
|
await this.files?.load(all.map((g) => g.id));
|
|
24571
|
+
if (!asItIs) await this.files?.sweep(all.map((g) => g.id));
|
|
24919
24572
|
for (const group of this.stored.values()) for (const [, linkId] of this.host.entries(group.id)) if (!group.invitation?.entry || group.invitation.linkId !== linkId) await this.host.closeEdge(linkId);
|
|
24920
24573
|
}
|
|
24921
24574
|
/** A group's pin now (WISP 400 § Pinned message), the latest one taken, unpinned or not. */
|
|
@@ -24971,7 +24624,7 @@ var Groups = class {
|
|
|
24971
24624
|
linkId: invitation.linkId,
|
|
24972
24625
|
contact: this.host.contactName(invitation.linkId) ?? "",
|
|
24973
24626
|
admin: invitation.admin,
|
|
24974
|
-
members: invitation.n,
|
|
24627
|
+
members: Math.min(Math.max(invitation.n, 0), GROUP_MEMBER_CAP.max),
|
|
24975
24628
|
accepted: !!invitation.seedB64,
|
|
24976
24629
|
...invitation.entry ? {
|
|
24977
24630
|
viaLink: true,
|
|
@@ -25112,8 +24765,21 @@ var Groups = class {
|
|
|
25112
24765
|
let set = this.invited.get(groupId);
|
|
25113
24766
|
if (!set) this.invited.set(groupId, set = /* @__PURE__ */ new Set());
|
|
25114
24767
|
set.add(linkId);
|
|
24768
|
+
group.invited = {
|
|
24769
|
+
...group.invited,
|
|
24770
|
+
[linkId]: Date.now()
|
|
24771
|
+
};
|
|
24772
|
+
await this.store.putGroup(group);
|
|
25115
24773
|
this.host.emit();
|
|
25116
24774
|
}
|
|
24775
|
+
/** Admin side: a contact chat answered its invitation (accepted or declined); false when it had none. */
|
|
24776
|
+
answered(group, linkId) {
|
|
24777
|
+
const had = !!this.invited.get(group.id)?.delete(linkId);
|
|
24778
|
+
if (!group.invited?.[linkId]) return had;
|
|
24779
|
+
const { [linkId]: _answered, ...rest } = group.invited;
|
|
24780
|
+
group.invited = Object.keys(rest).length ? rest : void 0;
|
|
24781
|
+
return true;
|
|
24782
|
+
}
|
|
25117
24783
|
async accept(groupId) {
|
|
25118
24784
|
const group = this.stored.get(groupId);
|
|
25119
24785
|
if (!group?.invitation) throw new Error("No invitation to accept");
|
|
@@ -25130,6 +24796,10 @@ var Groups = class {
|
|
|
25130
24796
|
g: groupId,
|
|
25131
24797
|
key: identityFromSeedB64(seedB64).pubKeyZ32
|
|
25132
24798
|
});
|
|
24799
|
+
this.acceptSaid.set(groupId, {
|
|
24800
|
+
at: this.now(),
|
|
24801
|
+
n: 0
|
|
24802
|
+
});
|
|
25133
24803
|
this.host.emit();
|
|
25134
24804
|
}
|
|
25135
24805
|
async decline(groupId) {
|
|
@@ -25142,7 +24812,12 @@ var Groups = class {
|
|
|
25142
24812
|
g: groupId
|
|
25143
24813
|
});
|
|
25144
24814
|
} catch {}
|
|
25145
|
-
|
|
24815
|
+
const previous = group.previous;
|
|
24816
|
+
if (!previous) return this.forget(groupId);
|
|
24817
|
+
this.stored.set(groupId, previous);
|
|
24818
|
+
if (previous.state) this.attach(previous.state);
|
|
24819
|
+
await this.store.putGroup(previous);
|
|
24820
|
+
this.host.emit();
|
|
25146
24821
|
}
|
|
25147
24822
|
/**
|
|
25148
24823
|
* Sends a text; `messageId` is the id it is kept under here (what history, replies and reactions name). `forwarded`:
|
|
@@ -25160,6 +24835,23 @@ var Groups = class {
|
|
|
25160
24835
|
};
|
|
25161
24836
|
}
|
|
25162
24837
|
/**
|
|
24838
|
+
* Whether a file of mine could be announced in the group now, asked before its bytes are copied in: the error
|
|
24839
|
+
* `sendFile` would answer for the pace or for not being in the group, or null. `sendFile` checks again.
|
|
24840
|
+
*/
|
|
24841
|
+
fileCheck(groupId) {
|
|
24842
|
+
if (!this.files) return {
|
|
24843
|
+
error: "This app takes no group files",
|
|
24844
|
+
refused: true
|
|
24845
|
+
};
|
|
24846
|
+
const paced = this.files.mayAnnounce(groupId);
|
|
24847
|
+
if (paced) return { error: paced };
|
|
24848
|
+
if (this.isCommunity(groupId)) return this.communities.sendCheck(groupId);
|
|
24849
|
+
const session = this.sessions.get(groupId);
|
|
24850
|
+
if (!session) return { error: "You are not in this group yet" };
|
|
24851
|
+
if (session.status !== "active") return { error: session.state.statusReason ?? "You are no longer in this group" };
|
|
24852
|
+
return { error: null };
|
|
24853
|
+
}
|
|
24854
|
+
/**
|
|
25163
24855
|
* Announces a file or voice message of mine (WISP 503): `text` is its caption, or the line older apps show
|
|
25164
24856
|
* (`groupFileFallback`), `file` its description with the digest of the bytes kept under `fileId`, which this device
|
|
25165
24857
|
* then holds and serves. `messageId` is the announcement's.
|
|
@@ -25238,8 +24930,8 @@ var Groups = class {
|
|
|
25238
24930
|
});
|
|
25239
24931
|
}
|
|
25240
24932
|
/**
|
|
25241
|
-
* Leaves, and the group is gone from this device at once: its row, its history and every
|
|
25242
|
-
* but the one to the admin, which stays until the admin's commit removing me comes back (or a
|
|
24933
|
+
* Leaves, and the group is gone from this device at once: its row, its history, its files and every
|
|
24934
|
+
* edge but the one to the admin, which stays until the admin's commit removing me comes back (or a
|
|
25243
24935
|
* week passes), so a leave said while the admin was away still reaches it. An admin with other
|
|
25244
24936
|
* members hands the role to one who is online first; alone, the group simply goes.
|
|
25245
24937
|
*/
|
|
@@ -25268,6 +24960,7 @@ var Groups = class {
|
|
|
25268
24960
|
...bye ? { bye } : {}
|
|
25269
24961
|
};
|
|
25270
24962
|
delete group.entry;
|
|
24963
|
+
delete group.invited;
|
|
25271
24964
|
this.invited.delete(groupId);
|
|
25272
24965
|
this.pendingEntries.delete(groupId);
|
|
25273
24966
|
this.welcomed.delete(groupId);
|
|
@@ -25277,9 +24970,18 @@ var Groups = class {
|
|
|
25277
24970
|
this.lastMentionAt.delete(groupId);
|
|
25278
24971
|
await this.store.deleteGroup(groupId);
|
|
25279
24972
|
await this.store.putGroup(group);
|
|
24973
|
+
await this.files?.drop(groupId);
|
|
25280
24974
|
this.host.historyGone?.(groupId);
|
|
25281
24975
|
for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
|
|
25282
24976
|
this.host.emit();
|
|
24977
|
+
if (this.hubs.isHub(groupId)) {
|
|
24978
|
+
await this.hubs.stepDown(groupId, session, this.now());
|
|
24979
|
+
if (bye) {
|
|
24980
|
+
for (const [key, edge] of this.host.edges(groupId)) if (!hubs.includes(key) && this.host.linkReady(edge)) try {
|
|
24981
|
+
this.host.sendOnLink(edge, bye);
|
|
24982
|
+
} catch {}
|
|
24983
|
+
}
|
|
24984
|
+
}
|
|
25283
24985
|
await session.leave();
|
|
25284
24986
|
if (bye) for (const key of hubs) {
|
|
25285
24987
|
const edge = this.host.edges(groupId).get(key);
|
|
@@ -25452,17 +25154,17 @@ var Groups = class {
|
|
|
25452
25154
|
await this.session(groupId).rename(name);
|
|
25453
25155
|
}
|
|
25454
25156
|
async forget(groupId) {
|
|
25455
|
-
if (this.isCommunity(groupId))
|
|
25456
|
-
|
|
25457
|
-
|
|
25458
|
-
return;
|
|
25459
|
-
}
|
|
25460
|
-
if (this.sessions.get(groupId)?.status === "active") try {
|
|
25157
|
+
if (this.isCommunity(groupId)) return this.communities.leave(groupId);
|
|
25158
|
+
const session = this.sessions.get(groupId);
|
|
25159
|
+
if (session?.status === "active") try {
|
|
25461
25160
|
await this.leave(groupId);
|
|
25462
|
-
} catch {
|
|
25161
|
+
} catch (e) {
|
|
25162
|
+
if (session.isAdmin && session.others.length) throw e;
|
|
25163
|
+
}
|
|
25463
25164
|
this.sessions.delete(groupId);
|
|
25464
25165
|
this.stored.delete(groupId);
|
|
25465
25166
|
this.invited.delete(groupId);
|
|
25167
|
+
this.acceptSaid.delete(groupId);
|
|
25466
25168
|
this.lastRoster.delete(groupId);
|
|
25467
25169
|
this.lastTold.delete(groupId);
|
|
25468
25170
|
this.pendingEntries.delete(groupId);
|
|
@@ -25476,6 +25178,8 @@ var Groups = class {
|
|
|
25476
25178
|
this.hubs.forget(groupId);
|
|
25477
25179
|
this.removedAt.delete(groupId);
|
|
25478
25180
|
this.lastGossip.delete(groupId);
|
|
25181
|
+
this.gapsAsked.delete(groupId);
|
|
25182
|
+
this.gapsLooked.delete(groupId);
|
|
25479
25183
|
this.typings.forget(groupId);
|
|
25480
25184
|
this.downSince.delete(groupId);
|
|
25481
25185
|
this.hereHeard.delete(groupId);
|
|
@@ -25530,8 +25234,7 @@ var Groups = class {
|
|
|
25530
25234
|
*/
|
|
25531
25235
|
async joinByLink(code) {
|
|
25532
25236
|
if (decodeCommunityLink(code)) {
|
|
25533
|
-
|
|
25534
|
-
if (this.stored.has(g)) await this.forget(g);
|
|
25237
|
+
if (this.stored.has(decodeCommunityLink(code).g)) throw new Error("This link names a private group of yours, not a community");
|
|
25535
25238
|
return this.communities.joinByLink(code);
|
|
25536
25239
|
}
|
|
25537
25240
|
const link = decodeGroupEntryLink(code);
|
|
@@ -25540,6 +25243,8 @@ var Groups = class {
|
|
|
25540
25243
|
const existing = this.stored.get(link.g);
|
|
25541
25244
|
if (existing?.state?.status === "active" || existing?.invitation?.entry === link.host) return link.g;
|
|
25542
25245
|
if (existing?.invitation?.seedB64 && !existing.invitation.entry) throw new Error("You are already joining this group");
|
|
25246
|
+
if (existing?.invitation && !existing.invitation.entry) throw new Error("You have an invitation to this group. Answer it first.");
|
|
25247
|
+
if (existing?.state && !existing.left) throw new Error("You are out of this group and its history is still here. Delete it from this device to join with a link.");
|
|
25543
25248
|
if (existing) await this.forget(link.g);
|
|
25544
25249
|
const seedB64 = dialedKey(link);
|
|
25545
25250
|
const linkId = await this.host.openEntry(link, "guest", seedB64, link.host);
|
|
@@ -25579,6 +25284,10 @@ var Groups = class {
|
|
|
25579
25284
|
if (!(this.host.linkReady(group.invitation.linkId) || !!this.host.linkSeen?.(group.invitation.linkId)) && (now - (this.lastKnock.get(group.id) ?? 0) >= every || now >= (this.knockCheckAt.get(group.id) ?? Infinity))) await this.knock(group, now).catch(() => {});
|
|
25580
25285
|
continue;
|
|
25581
25286
|
}
|
|
25287
|
+
if (group.invitation?.seedB64 && !group.state) {
|
|
25288
|
+
this.acceptAgain(group, now);
|
|
25289
|
+
continue;
|
|
25290
|
+
}
|
|
25582
25291
|
const session = this.sessions.get(group.id);
|
|
25583
25292
|
if (!group.entry || !session) continue;
|
|
25584
25293
|
if (!session.isAdmin) {
|
|
@@ -25625,6 +25334,7 @@ var Groups = class {
|
|
|
25625
25334
|
}
|
|
25626
25335
|
for (const [groupId, session] of this.sessions) {
|
|
25627
25336
|
const group = this.stored.get(groupId);
|
|
25337
|
+
if (session.status !== "active" && !this.removedAt.has(groupId) && this.hubs.isHub(groupId)) this.hubs.forget(groupId);
|
|
25628
25338
|
if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
|
|
25629
25339
|
await this.hubs.tick(groupId, session, group, now).catch(() => false);
|
|
25630
25340
|
if (this.hubReconcile.has(groupId)) continue;
|
|
@@ -25636,6 +25346,39 @@ var Groups = class {
|
|
|
25636
25346
|
}
|
|
25637
25347
|
}
|
|
25638
25348
|
}
|
|
25349
|
+
/**
|
|
25350
|
+
* Invitee side: my accept, not answered with a welcome yet, goes again over the inviter's chat (`ACCEPT_AGAIN_MS`):
|
|
25351
|
+
* the admin's app may have been closed or started again since, and an accept it never read waits for good otherwise.
|
|
25352
|
+
* An admin that let me in already, or never invited me, ignores it.
|
|
25353
|
+
*/
|
|
25354
|
+
acceptAgain(group, now) {
|
|
25355
|
+
const invitation = group.invitation, said = this.acceptSaid.get(group.id) ?? {
|
|
25356
|
+
at: 0,
|
|
25357
|
+
n: 0
|
|
25358
|
+
};
|
|
25359
|
+
if (!this.host.linkReady(invitation.linkId)) {
|
|
25360
|
+
if (!said.down) this.acceptSaid.set(group.id, {
|
|
25361
|
+
...said,
|
|
25362
|
+
n: 0,
|
|
25363
|
+
down: true
|
|
25364
|
+
});
|
|
25365
|
+
return;
|
|
25366
|
+
}
|
|
25367
|
+
if (said.n >= ACCEPT_AGAINS || !said.down && now - said.at < ACCEPT_AGAIN_MS) return;
|
|
25368
|
+
try {
|
|
25369
|
+
this.host.sendOnLink(invitation.linkId, {
|
|
25370
|
+
t: "group-accept",
|
|
25371
|
+
g: group.id,
|
|
25372
|
+
key: identityFromSeedB64(invitation.seedB64).pubKeyZ32
|
|
25373
|
+
});
|
|
25374
|
+
} catch {
|
|
25375
|
+
return;
|
|
25376
|
+
}
|
|
25377
|
+
this.acceptSaid.set(group.id, {
|
|
25378
|
+
at: now,
|
|
25379
|
+
n: said.n + 1
|
|
25380
|
+
});
|
|
25381
|
+
}
|
|
25639
25382
|
/** An entry session came up with groups on both sides: the admin's side invites over it. */
|
|
25640
25383
|
entryReady(groupId, linkId, peer) {
|
|
25641
25384
|
if (this.isCommunity(groupId)) {
|
|
@@ -25806,8 +25549,7 @@ var Groups = class {
|
|
|
25806
25549
|
name: groupName(frame.name) ?? "Group",
|
|
25807
25550
|
admin: frame.admin,
|
|
25808
25551
|
pieces: [],
|
|
25809
|
-
|
|
25810
|
-
n: Number.isSafeInteger(frame.n) ? frame.n : 1
|
|
25552
|
+
...invitationClaims(frame)
|
|
25811
25553
|
};
|
|
25812
25554
|
await this.store.putGroup(existing);
|
|
25813
25555
|
traceJoin(g, "invite.received");
|
|
@@ -25819,13 +25561,29 @@ var Groups = class {
|
|
|
25819
25561
|
this.host.emit();
|
|
25820
25562
|
return;
|
|
25821
25563
|
}
|
|
25822
|
-
if (existing?.state && existing.state.status === "active")
|
|
25823
|
-
|
|
25564
|
+
if (existing?.state && existing.state.status === "active") {
|
|
25565
|
+
if (this.sessions.get(g)?.admin === frame.admin) this.host.sendOnLink(linkId, {
|
|
25566
|
+
t: "group-decline",
|
|
25567
|
+
g
|
|
25568
|
+
});
|
|
25569
|
+
return;
|
|
25570
|
+
}
|
|
25571
|
+
if (existing?.invitation?.seedB64 && (existing.invitation.linkId !== linkId || existing.invitation.admin !== frame.admin)) return;
|
|
25824
25572
|
if ([...this.stored.values()].filter((x) => x.invitation).length >= 32) return;
|
|
25573
|
+
if (!existing?.invitation && [...this.stored.values()].filter((x) => x.invitation?.linkId === linkId).length >= MAX_INVITATIONS_PER_CHAT) {
|
|
25574
|
+
try {
|
|
25575
|
+
this.host.sendOnLink(linkId, {
|
|
25576
|
+
t: "group-decline",
|
|
25577
|
+
g
|
|
25578
|
+
});
|
|
25579
|
+
} catch {}
|
|
25580
|
+
return;
|
|
25581
|
+
}
|
|
25825
25582
|
const known = {
|
|
25826
25583
|
...existing?.formerNames,
|
|
25827
25584
|
...existing?.state?.nicks
|
|
25828
25585
|
};
|
|
25586
|
+
const previous = existing?.state ? existing : existing?.previous;
|
|
25829
25587
|
const invitation = {
|
|
25830
25588
|
id: g,
|
|
25831
25589
|
createdAt: Date.now(),
|
|
@@ -25833,17 +25591,18 @@ var Groups = class {
|
|
|
25833
25591
|
name: groupName(frame.name) ?? "Group",
|
|
25834
25592
|
admin: frame.admin,
|
|
25835
25593
|
linkId,
|
|
25836
|
-
|
|
25837
|
-
n: Number.isSafeInteger(frame.n) ? frame.n : 1,
|
|
25594
|
+
...invitationClaims(frame),
|
|
25838
25595
|
pieces: []
|
|
25839
25596
|
},
|
|
25840
|
-
...Object.keys(known).length ? { formerNames: known } : {}
|
|
25597
|
+
...Object.keys(known).length ? { formerNames: known } : {},
|
|
25598
|
+
...previous ? { previous } : {}
|
|
25841
25599
|
};
|
|
25842
25600
|
if (existing) {
|
|
25843
25601
|
this.sessions.delete(g);
|
|
25844
25602
|
for (const edge of this.host.edges(g).values()) await this.host.closeEdge(edge);
|
|
25845
25603
|
}
|
|
25846
25604
|
this.stored.set(g, invitation);
|
|
25605
|
+
this.acceptSaid.delete(g);
|
|
25847
25606
|
await this.store.putGroup(invitation);
|
|
25848
25607
|
this.host.emit();
|
|
25849
25608
|
return;
|
|
@@ -25852,6 +25611,7 @@ var Groups = class {
|
|
|
25852
25611
|
const session = this.sessions.get(g);
|
|
25853
25612
|
if (!session?.isAdmin || !this.invited.get(g)?.has(linkId) || typeof frame.key !== "string" || !MEMBER_KEY.test(frame.key)) return;
|
|
25854
25613
|
const group = this.stored.get(g);
|
|
25614
|
+
if (frame.key === session.myKey || rosterHas(session.roster, frame.key)) return;
|
|
25855
25615
|
const entryPeer = this.hostEntry(g, linkId);
|
|
25856
25616
|
if ([...this.host.entries(g).values()].includes(linkId) && entryPeer !== frame.key) return;
|
|
25857
25617
|
if (session.roster.length >= GROUP_MEMBER_CAP.max || this.growthBlocked(group, session, session.roster.length + 1, linkId)) return;
|
|
@@ -25863,8 +25623,8 @@ var Groups = class {
|
|
|
25863
25623
|
...group.contacts,
|
|
25864
25624
|
[frame.key]: linkId
|
|
25865
25625
|
};
|
|
25626
|
+
this.answered(group, linkId);
|
|
25866
25627
|
await this.store.putGroup(group);
|
|
25867
|
-
this.invited.get(g)?.delete(linkId);
|
|
25868
25628
|
for (const piece of welcome) this.host.sendOnLink(linkId, piece);
|
|
25869
25629
|
if (entryPeer) traceJoin(g, "welcome.sent");
|
|
25870
25630
|
if (entryPeer) {
|
|
@@ -25888,7 +25648,10 @@ var Groups = class {
|
|
|
25888
25648
|
this.pendingEntries.get(g)?.delete(entryPeer);
|
|
25889
25649
|
await this.host.closeEdge(linkId);
|
|
25890
25650
|
}
|
|
25891
|
-
|
|
25651
|
+
const group = this.stored.get(g);
|
|
25652
|
+
if (!group || !this.answered(group, linkId)) return;
|
|
25653
|
+
await this.store.putGroup(group);
|
|
25654
|
+
this.host.emit();
|
|
25892
25655
|
return;
|
|
25893
25656
|
}
|
|
25894
25657
|
case "group-chain": {
|
|
@@ -25922,6 +25685,7 @@ var Groups = class {
|
|
|
25922
25685
|
contacts: viaLink ? {} : { [group.invitation.admin]: linkId }
|
|
25923
25686
|
};
|
|
25924
25687
|
this.stored.set(g, member);
|
|
25688
|
+
this.acceptSaid.delete(g);
|
|
25925
25689
|
this.attach(joined.state);
|
|
25926
25690
|
if (viaLink) this.lingering.set(g, {
|
|
25927
25691
|
linkId,
|
|
@@ -26017,6 +25781,7 @@ var Groups = class {
|
|
|
26017
25781
|
return;
|
|
26018
25782
|
}
|
|
26019
25783
|
if (!session) return;
|
|
25784
|
+
if (t === "group-bye" && session.status === "active" && frame.k === peerKey && this.hubs.departs(groupId, peerKey)) this.host.emit();
|
|
26020
25785
|
if (t === "group-react" || t === "group-reacted") {
|
|
26021
25786
|
await this.reaction(groupId, session, peerKey, frame);
|
|
26022
25787
|
return;
|
|
@@ -26107,6 +25872,9 @@ var Groups = class {
|
|
|
26107
25872
|
} catch {
|
|
26108
25873
|
return;
|
|
26109
25874
|
}
|
|
25875
|
+
if (peerKey === session.admin) try {
|
|
25876
|
+
for (const bye of session.byesHeld()) this.host.sendOnLink(linkId, bye);
|
|
25877
|
+
} catch {}
|
|
26110
25878
|
const kept = this.lingering.get(groupId);
|
|
26111
25879
|
if (kept?.admin === peerKey) {
|
|
26112
25880
|
if (this.signals.keptEntry(groupId) === kept.linkId) setTimeout(() => {
|
|
@@ -26222,13 +25990,123 @@ var Groups = class {
|
|
|
26222
25990
|
});
|
|
26223
25991
|
out.push(key);
|
|
26224
25992
|
}
|
|
25993
|
+
out.push(...this.gapsOf(groupId, session, via, session.lacking()));
|
|
26225
25994
|
if (out.length) this.lastGossip.set(groupId, now);
|
|
26226
25995
|
return out;
|
|
26227
25996
|
}
|
|
26228
25997
|
/**
|
|
26229
|
-
*
|
|
26230
|
-
*
|
|
26231
|
-
*
|
|
25998
|
+
* Reachable members whose messages below the highest seen never reached me, for `via` to hand on: their own answer
|
|
25999
|
+
* comes from their last `GROUP_LIMITS.outlog` only (WISP 902 § Catch-up), the rest another member may hold. As
|
|
26000
|
+
* `askOf`, one member at a time, and each member once until what is missing changes.
|
|
26001
|
+
*/
|
|
26002
|
+
gapsOf(groupId, session, via, lacking) {
|
|
26003
|
+
if (!lacking.size) return [];
|
|
26004
|
+
let asked = this.relayAsked.get(groupId), tried = this.gapsAsked.get(groupId);
|
|
26005
|
+
if (!asked) this.relayAsked.set(groupId, asked = /* @__PURE__ */ new Map());
|
|
26006
|
+
if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
|
|
26007
|
+
const edges = this.host.edges(groupId), now = this.now();
|
|
26008
|
+
const up = (key) => {
|
|
26009
|
+
const edge = edges.get(key);
|
|
26010
|
+
return !!edge && this.host.linkReady(edge);
|
|
26011
|
+
};
|
|
26012
|
+
for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
|
|
26013
|
+
const out = [];
|
|
26014
|
+
for (const [key, mark] of lacking) {
|
|
26015
|
+
if (key === via || !up(key)) continue;
|
|
26016
|
+
const prior = asked.get(key);
|
|
26017
|
+
if (prior && prior.via !== via && up(prior.via) && now - prior.at < 6e4) continue;
|
|
26018
|
+
let entry = tried.get(key);
|
|
26019
|
+
if (!entry || entry.mark !== mark) tried.set(key, entry = {
|
|
26020
|
+
mark,
|
|
26021
|
+
of: /* @__PURE__ */ new Set(),
|
|
26022
|
+
noticed: entry?.noticed,
|
|
26023
|
+
last: entry?.last
|
|
26024
|
+
});
|
|
26025
|
+
if (entry.of.has(via)) continue;
|
|
26026
|
+
entry.of.add(via);
|
|
26027
|
+
entry.last = {
|
|
26028
|
+
via,
|
|
26029
|
+
at: now,
|
|
26030
|
+
asked: session.missOf(key)
|
|
26031
|
+
};
|
|
26032
|
+
asked.set(key, {
|
|
26033
|
+
via,
|
|
26034
|
+
at: now
|
|
26035
|
+
});
|
|
26036
|
+
out.push(key);
|
|
26037
|
+
}
|
|
26038
|
+
return out;
|
|
26039
|
+
}
|
|
26040
|
+
/**
|
|
26041
|
+
* Gaps asked for sooner than the gossip turn: one noticed `MESH_GAP_AGAIN_MS` ago and not asked for yet (an author's
|
|
26042
|
+
* own answer, its last 32, showed what is below them) goes to a connected member now; and a member that handed on some
|
|
26043
|
+
* of what I last asked it for likely holds the rest, so it is asked for the next numbers `MESH_GAP_AGAIN_MS` after. A
|
|
26044
|
+
* member back after 200 messages of one author has them in about a minute, not 32 a gossip turn. One that handed
|
|
26045
|
+
* nothing on waits for the gossip turn, as before.
|
|
26046
|
+
*/
|
|
26047
|
+
gapsAgain(groupId, session, now, connected) {
|
|
26048
|
+
if (now - (this.gapsLooked.get(groupId) ?? -Infinity) < 5e3) return;
|
|
26049
|
+
this.gapsLooked.set(groupId, now);
|
|
26050
|
+
const lacking = session.lacking();
|
|
26051
|
+
let tried = this.gapsAsked.get(groupId);
|
|
26052
|
+
if (!lacking.size && !tried?.size) return;
|
|
26053
|
+
if (!tried) this.gapsAsked.set(groupId, tried = /* @__PURE__ */ new Map());
|
|
26054
|
+
for (const key of tried.keys()) if (!lacking.has(key)) tried.delete(key);
|
|
26055
|
+
const edges = this.host.edges(groupId), again = /* @__PURE__ */ new Map(), fresh = /* @__PURE__ */ new Map();
|
|
26056
|
+
const up = (key) => {
|
|
26057
|
+
const edge = edges.get(key);
|
|
26058
|
+
return !!edge && this.host.linkReady(edge);
|
|
26059
|
+
};
|
|
26060
|
+
for (const [key, mark] of lacking) {
|
|
26061
|
+
let entry = tried.get(key);
|
|
26062
|
+
if (!entry) tried.set(key, entry = {
|
|
26063
|
+
mark,
|
|
26064
|
+
of: /* @__PURE__ */ new Set(),
|
|
26065
|
+
noticed: now
|
|
26066
|
+
});
|
|
26067
|
+
const { last } = entry;
|
|
26068
|
+
if (!up(key)) continue;
|
|
26069
|
+
if (!last) {
|
|
26070
|
+
if (now - (entry.noticed ?? now) >= 1e4) fresh.set(key, mark);
|
|
26071
|
+
continue;
|
|
26072
|
+
}
|
|
26073
|
+
if (now - last.at < 1e4 || !up(last.via)) continue;
|
|
26074
|
+
if (session.filledSince(key, last.asked)) again.set(last.via, [...again.get(last.via) ?? [], key]);
|
|
26075
|
+
}
|
|
26076
|
+
const asked = this.relayAsked.get(groupId);
|
|
26077
|
+
for (const [via, keys] of again) {
|
|
26078
|
+
for (const key of keys) {
|
|
26079
|
+
tried.set(key, {
|
|
26080
|
+
mark: lacking.get(key),
|
|
26081
|
+
of: /* @__PURE__ */ new Set([via]),
|
|
26082
|
+
last: {
|
|
26083
|
+
via,
|
|
26084
|
+
at: now,
|
|
26085
|
+
asked: session.missOf(key)
|
|
26086
|
+
}
|
|
26087
|
+
});
|
|
26088
|
+
asked?.set(key, {
|
|
26089
|
+
via,
|
|
26090
|
+
at: now
|
|
26091
|
+
});
|
|
26092
|
+
}
|
|
26093
|
+
try {
|
|
26094
|
+
this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
|
|
26095
|
+
} catch {}
|
|
26096
|
+
}
|
|
26097
|
+
const others = connected.filter((key) => !fresh.has(key)), pool = others.length ? others : connected;
|
|
26098
|
+
if (!fresh.size || !pool.length) return;
|
|
26099
|
+
const via = pool[(gossipStart(session.myKey) + this.gossipTurn++) % pool.length];
|
|
26100
|
+
const keys = this.gapsOf(groupId, session, via, fresh);
|
|
26101
|
+
if (keys.length) try {
|
|
26102
|
+
this.host.sendOnLink(edges.get(via), session.syncFrame(keys));
|
|
26103
|
+
} catch {}
|
|
26104
|
+
}
|
|
26105
|
+
/**
|
|
26106
|
+
* While some member is unreachable, or some of a member's messages never reached me (`gapsOf`), one connected member
|
|
26107
|
+
* (in turn) is asked, once a `MESH_GOSSIP_MS`, for what it has from them: a message sent while the author could reach
|
|
26108
|
+
* it and not me gets here within that, without either edge opening again. Edges the roster asks for and that do not
|
|
26109
|
+
* exist are opened again.
|
|
26232
26110
|
*/
|
|
26233
26111
|
meshTick(now) {
|
|
26234
26112
|
this.tickNow = now;
|
|
@@ -26244,10 +26122,13 @@ var Groups = class {
|
|
|
26244
26122
|
}
|
|
26245
26123
|
const away = this.unreachable(groupId, session);
|
|
26246
26124
|
this.noteDown(groupId, session, away, now);
|
|
26125
|
+
const connected = session.others.filter((key) => !away.includes(key));
|
|
26126
|
+
this.gapsAgain(groupId, session, now, connected);
|
|
26247
26127
|
if (now - (this.lastGossip.get(groupId) ?? -Infinity) < 6e4) continue;
|
|
26248
26128
|
const edges = this.host.edges(groupId);
|
|
26249
|
-
|
|
26250
|
-
|
|
26129
|
+
if (!connected.length) continue;
|
|
26130
|
+
const lacking = session.lacking();
|
|
26131
|
+
if (!away.length && !lacking.size) continue;
|
|
26251
26132
|
this.lastGossip.set(groupId, now);
|
|
26252
26133
|
const via = connected[(gossipStart(session.myKey) + this.gossipTurn++) % connected.length];
|
|
26253
26134
|
const asked = this.relayAsked.get(groupId) ?? /* @__PURE__ */ new Map();
|
|
@@ -26256,8 +26137,10 @@ var Groups = class {
|
|
|
26256
26137
|
via,
|
|
26257
26138
|
at: now
|
|
26258
26139
|
});
|
|
26140
|
+
const ask = [...away, ...this.gapsOf(groupId, session, via, lacking)];
|
|
26141
|
+
if (!ask.length) continue;
|
|
26259
26142
|
try {
|
|
26260
|
-
this.host.sendOnLink(edges.get(via), session.syncFrame(
|
|
26143
|
+
this.host.sendOnLink(edges.get(via), session.syncFrame(ask));
|
|
26261
26144
|
} catch {}
|
|
26262
26145
|
}
|
|
26263
26146
|
}
|
|
@@ -26402,7 +26285,7 @@ var Groups = class {
|
|
|
26402
26285
|
}
|
|
26403
26286
|
/** `at`: when the admin signed the statement (the line's time, as a commit's is its line's). */
|
|
26404
26287
|
async metaChanged(groupId, session, by, change, at) {
|
|
26405
|
-
const name = by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
|
|
26288
|
+
const name = by === void 0 ? void 0 : by === session.myKey ? "You" : session.state.nicks[by] ?? `Member ${by.slice(0, 8)}`;
|
|
26406
26289
|
const when = at === void 0 ? Date.now() : receivedTimestamp(at);
|
|
26407
26290
|
for (const line of metaLines(name, change, session.name)) await this.event(groupId, line.event, line.text, when, session.epoch, by);
|
|
26408
26291
|
this.host.emit();
|
|
@@ -26997,7 +26880,8 @@ var GroupEdits = class {
|
|
|
26997
26880
|
}
|
|
26998
26881
|
/** An edge of a private group opened: the member at its other end hears my latest edits again, in case it missed them. */
|
|
26999
26882
|
async resend(groupId, to) {
|
|
27000
|
-
const
|
|
26883
|
+
const chat = chatOf(groupId);
|
|
26884
|
+
const mine = (await (this.host.history ? this.host.history(chat) : this.host.messages(chat))).filter((m) => m.sender === "me" && m.edit && !m.edit.pending && canEditInGroup(m)).sort((a, b) => b.edit.at - a.edit.at).slice(0, 16).reverse();
|
|
27001
26885
|
for (const m of mine) await this.host.send(groupId, {
|
|
27002
26886
|
id: m.id,
|
|
27003
26887
|
e: m.edit.seq,
|
|
@@ -27137,7 +27021,8 @@ var CommunityPay = class {
|
|
|
27137
27021
|
},
|
|
27138
27022
|
memo: frame.memo,
|
|
27139
27023
|
endpoints: frame.e,
|
|
27140
|
-
ask: frame.a
|
|
27024
|
+
ask: frame.a,
|
|
27025
|
+
network: frame.n
|
|
27141
27026
|
});
|
|
27142
27027
|
else if (frame.t === "pay-ask") await this.host.onPaymentAsk(linkId, {
|
|
27143
27028
|
id: frame.id,
|
|
@@ -27147,7 +27032,8 @@ var CommunityPay = class {
|
|
|
27147
27032
|
asset: frame.u
|
|
27148
27033
|
},
|
|
27149
27034
|
method: frame.m,
|
|
27150
|
-
memo: frame.memo
|
|
27035
|
+
memo: frame.memo,
|
|
27036
|
+
network: frame.n
|
|
27151
27037
|
});
|
|
27152
27038
|
else if (frame.t === "pay") await this.host.onPayment(linkId, {
|
|
27153
27039
|
id: frame.id,
|
|
@@ -27191,7 +27077,8 @@ var CommunityPay = class {
|
|
|
27191
27077
|
asset: frame.u
|
|
27192
27078
|
},
|
|
27193
27079
|
memo: frame.memo,
|
|
27194
|
-
endpoints: frame.e
|
|
27080
|
+
endpoints: frame.e,
|
|
27081
|
+
network: frame.n
|
|
27195
27082
|
});
|
|
27196
27083
|
else if (frame.t === "pay-res" && frame.ok) await this.host.onPaymentResult(linkId, {
|
|
27197
27084
|
id: frame.id,
|
|
@@ -27313,7 +27200,8 @@ var PairLink = class {
|
|
|
27313
27200
|
u: r.amount.asset,
|
|
27314
27201
|
memo: r.memo,
|
|
27315
27202
|
e: r.endpoints,
|
|
27316
|
-
a: r.ask
|
|
27203
|
+
a: r.ask,
|
|
27204
|
+
n: r.network
|
|
27317
27205
|
});
|
|
27318
27206
|
}
|
|
27319
27207
|
async sendPaymentAsk(a) {
|
|
@@ -27325,7 +27213,8 @@ var PairLink = class {
|
|
|
27325
27213
|
v: a.amount.value,
|
|
27326
27214
|
u: a.amount.asset,
|
|
27327
27215
|
m: a.method,
|
|
27328
|
-
memo: a.memo
|
|
27216
|
+
memo: a.memo,
|
|
27217
|
+
n: a.network
|
|
27329
27218
|
});
|
|
27330
27219
|
}
|
|
27331
27220
|
async sendPayment(p) {
|
|
@@ -27406,7 +27295,8 @@ var GroupLink = class {
|
|
|
27406
27295
|
v: r.amount.value,
|
|
27407
27296
|
u: r.amount.asset,
|
|
27408
27297
|
memo: r.memo,
|
|
27409
|
-
e: r.endpoints
|
|
27298
|
+
e: r.endpoints,
|
|
27299
|
+
n: r.network
|
|
27410
27300
|
}));
|
|
27411
27301
|
await this.host.sendApp(this.groupId, {
|
|
27412
27302
|
...frame,
|
|
@@ -28065,16 +27955,17 @@ var HoldEngine = class {
|
|
|
28065
27955
|
}, held);
|
|
28066
27956
|
} else if (header.kind === "file") {
|
|
28067
27957
|
const meta = header.meta;
|
|
28068
|
-
const
|
|
28069
|
-
const
|
|
28070
|
-
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);
|
|
28071
27962
|
const reply = readReply(meta.r, pairedReplyAuthor);
|
|
28072
27963
|
const forwarded = readForwarded(meta.fw);
|
|
28073
27964
|
const refused = await this.host.receiveFile(linkId, {
|
|
28074
27965
|
wireId: header.id,
|
|
28075
|
-
name
|
|
27966
|
+
name,
|
|
28076
27967
|
size: meta.size,
|
|
28077
|
-
mime
|
|
27968
|
+
mime,
|
|
28078
27969
|
timestamp: header.ts,
|
|
28079
27970
|
...voice && { voice },
|
|
28080
27971
|
...video && { video },
|
|
@@ -29376,6 +29267,9 @@ function assertAmount(amount) {
|
|
|
29376
29267
|
var asProofLike = (proofs) => proofs;
|
|
29377
29268
|
var total = (proofs) => proofs.reduce((sum, p) => sum + p.amount, 0);
|
|
29378
29269
|
var sats = (proofs) => total(proofs.map((p) => ({ amount: p.amount.toNumber() })));
|
|
29270
|
+
var MINT_QUOTE_MISMATCH = "The mint quoted another amount than this invoice's. Nothing was paid.";
|
|
29271
|
+
/** A melt quote that is not for this invoice (`invoice` lower case, as `decodeBolt11` gives it) and its amount. */
|
|
29272
|
+
var quoteMismatch = (quote, invoice, amount) => quote.request.trim().toLowerCase().replace(/^lightning:/, "") !== invoice || quote.amount.toNumber() !== amount;
|
|
29379
29273
|
var toStored = (mint, p, reserved) => ({
|
|
29380
29274
|
mint,
|
|
29381
29275
|
id: p.id,
|
|
@@ -29428,6 +29322,8 @@ var CashuWallet = class CashuWallet {
|
|
|
29428
29322
|
failedFees = /* @__PURE__ */ new Map();
|
|
29429
29323
|
/** Melt quotes that ended unpaid with proofs the mint read spent: ecash spent somewhere else, said as such. */
|
|
29430
29324
|
spentAway = /* @__PURE__ */ new Set();
|
|
29325
|
+
/** Per melt quote shown to the person: the invoice, its amount and the fee reserve the mint quoted. Only these are paid. */
|
|
29326
|
+
shownQuotes = /* @__PURE__ */ new Map();
|
|
29431
29327
|
/**
|
|
29432
29328
|
* `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
|
|
29433
29329
|
* invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
|
|
@@ -29469,7 +29365,10 @@ var CashuWallet = class CashuWallet {
|
|
|
29469
29365
|
}
|
|
29470
29366
|
/** How long `stop` waits for a swap in a per-mint lock. */
|
|
29471
29367
|
static STOP_WAIT_MS = 3e4;
|
|
29472
|
-
/**
|
|
29368
|
+
/**
|
|
29369
|
+
* The Cashu wallet of one network: its mints, their balance, and the network's newest movements (every network's when
|
|
29370
|
+
* none is named) with the fees they paid. Only those movements are read, however long the history is.
|
|
29371
|
+
*/
|
|
29473
29372
|
async view(network) {
|
|
29474
29373
|
const proofs = await this.allProofs();
|
|
29475
29374
|
const mints = this.getMints(network).map((url) => ({
|
|
@@ -29478,8 +29377,7 @@ var CashuWallet = class CashuWallet {
|
|
|
29478
29377
|
balance: total(proofs.filter((p) => p.mint === url && !p.reserved)),
|
|
29479
29378
|
info: this.infos.get(url) ?? null
|
|
29480
29379
|
}));
|
|
29481
|
-
const
|
|
29482
|
-
all.sort((a, b) => b.timestamp - a.timestamp);
|
|
29380
|
+
const history = await newestWalletTx((mint) => !network || mintNetwork(mint) === network, HISTORY_SHOWN);
|
|
29483
29381
|
const here = new Set(mints.map((m) => m.url));
|
|
29484
29382
|
const swaps = (await wrap((await store(STORES$1.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
|
|
29485
29383
|
const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
|
|
@@ -29491,8 +29389,8 @@ var CashuWallet = class CashuWallet {
|
|
|
29491
29389
|
openSwaps: swaps.length,
|
|
29492
29390
|
swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
|
|
29493
29391
|
unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
|
|
29494
|
-
history
|
|
29495
|
-
feesPaid:
|
|
29392
|
+
history,
|
|
29393
|
+
feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0)
|
|
29496
29394
|
};
|
|
29497
29395
|
}
|
|
29498
29396
|
/**
|
|
@@ -30056,7 +29954,7 @@ var CashuWallet = class CashuWallet {
|
|
|
30056
29954
|
unit: metadata.unit,
|
|
30057
29955
|
mint,
|
|
30058
29956
|
memo: metadata.memo || void 0,
|
|
30059
|
-
accepted: this.getKnownMints().includes(mint) || isTestMint(mint)
|
|
29957
|
+
accepted: metadata.unit === UNIT$1 && (this.getKnownMints().includes(mint) || isTestMint(mint))
|
|
30060
29958
|
};
|
|
30061
29959
|
}
|
|
30062
29960
|
if (/^creq[AB]/i.test(value)) {
|
|
@@ -30079,14 +29977,16 @@ var CashuWallet = class CashuWallet {
|
|
|
30079
29977
|
async receiveToken(token, kind = "ecash-in", note, { addTestMint = true, payment } = {}) {
|
|
30080
29978
|
let mint;
|
|
30081
29979
|
let faceValue;
|
|
29980
|
+
let unit;
|
|
30082
29981
|
try {
|
|
30083
29982
|
const metadata = getTokenMetadata(token);
|
|
30084
|
-
|
|
29983
|
+
unit = metadata.unit;
|
|
30085
29984
|
mint = metadata.mint.replace(/\/+$/, "");
|
|
30086
29985
|
faceValue = Number(metadata.amount);
|
|
30087
29986
|
} catch {
|
|
30088
29987
|
throw new Error("That is not a valid ecash token");
|
|
30089
29988
|
}
|
|
29989
|
+
if (unit !== UNIT$1) throw engineError("ecashOtherUnit", { unit });
|
|
30090
29990
|
if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
|
|
30091
29991
|
if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
|
|
30092
29992
|
return this.locked(mint, async () => {
|
|
@@ -30135,9 +30035,10 @@ var CashuWallet = class CashuWallet {
|
|
|
30135
30035
|
}
|
|
30136
30036
|
/**
|
|
30137
30037
|
* Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
|
|
30138
|
-
* no mint
|
|
30139
|
-
*
|
|
30140
|
-
*
|
|
30038
|
+
* no mint, as long as their token fits in a payment frame: a wallet of many small coins can add up exactly in
|
|
30039
|
+
* hundreds of them, a token the contact's app drops unread. Otherwise it is a swap, which sends a few coins: written
|
|
30040
|
+
* down, with its inputs reserved, before the mint is asked. The caller stores the result in one transaction: `inputs`
|
|
30041
|
+
* out, `keep` in, `send` where it goes, and `swap` deleted. Runs under the mint's lock.
|
|
30141
30042
|
*/
|
|
30142
30043
|
async split(mint, amount, proofs) {
|
|
30143
30044
|
const wallet = await this.wallet(mint);
|
|
@@ -30150,7 +30051,11 @@ var CashuWallet = class CashuWallet {
|
|
|
30150
30051
|
includeFees: true,
|
|
30151
30052
|
exactMatch: true
|
|
30152
30053
|
});
|
|
30153
|
-
if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber()
|
|
30054
|
+
if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber() && getEncodedToken({
|
|
30055
|
+
mint,
|
|
30056
|
+
proofs: send,
|
|
30057
|
+
unit: UNIT$1
|
|
30058
|
+
}).length <= LIMITS.maxPaymentEndpointChars) return {
|
|
30154
30059
|
inputs: among(send),
|
|
30155
30060
|
keep: [],
|
|
30156
30061
|
send
|
|
@@ -30467,7 +30372,10 @@ var CashuWallet = class CashuWallet {
|
|
|
30467
30372
|
async quoteInvoice(invoice, network) {
|
|
30468
30373
|
let lastError = /* @__PURE__ */ new Error("Add a mint in Settings first");
|
|
30469
30374
|
let mints = this.getMints(network);
|
|
30470
|
-
|
|
30375
|
+
const decoded = decodeBolt11(invoice);
|
|
30376
|
+
if (!decoded) throw new Error("That is not an invoice this wallet can pay");
|
|
30377
|
+
if (decoded.amountSat === null) throw new Error("Invoices without an amount are not supported");
|
|
30378
|
+
if (decoded.network === "bitcoin" && mints.some((mint) => mintNetwork(mint) === "testnet")) {
|
|
30471
30379
|
mints = mints.filter((mint) => mintNetwork(mint) === "mainnet" || fakesLightning(mint));
|
|
30472
30380
|
if (!mints.length) throw new Error(BITCOIN_INVOICE_ON_TESTNET);
|
|
30473
30381
|
}
|
|
@@ -30475,10 +30383,16 @@ var CashuWallet = class CashuWallet {
|
|
|
30475
30383
|
const quote = await (await this.wallet(mint)).createMeltQuoteBolt11(invoice.trim());
|
|
30476
30384
|
const amount = quote.amount.toNumber();
|
|
30477
30385
|
const feeReserve = quote.fee_reserve.toNumber();
|
|
30386
|
+
if (quoteMismatch(quote, decoded.invoice, decoded.amountSat)) throw new Error(MINT_QUOTE_MISMATCH);
|
|
30478
30387
|
if (await this.balanceAt(mint) < amount + feeReserve) {
|
|
30479
30388
|
lastError = engineError("notEnoughSats");
|
|
30480
30389
|
continue;
|
|
30481
30390
|
}
|
|
30391
|
+
this.shownQuotes.set(quote.quote, {
|
|
30392
|
+
request: decoded.invoice,
|
|
30393
|
+
amount,
|
|
30394
|
+
feeReserve
|
|
30395
|
+
});
|
|
30482
30396
|
return {
|
|
30483
30397
|
quote: quote.quote,
|
|
30484
30398
|
mint,
|
|
@@ -30510,6 +30424,8 @@ var CashuWallet = class CashuWallet {
|
|
|
30510
30424
|
const wallet = await this.wallet(mint);
|
|
30511
30425
|
const quote = await wallet.checkMeltQuoteBolt11(quoteId);
|
|
30512
30426
|
if ((await wrap((await store(STORES$1.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
|
|
30427
|
+
const shown = this.shownQuotes.get(quoteId);
|
|
30428
|
+
if (!shown || quote.quote !== quoteId || quoteMismatch(quote, shown.request, shown.amount) || quote.fee_reserve.toNumber() > shown.feeReserve) throw new Error(MINT_QUOTE_MISMATCH);
|
|
30513
30429
|
const needed = quote.amount.toNumber() + quote.fee_reserve.toNumber();
|
|
30514
30430
|
const { inputs, keep, send, swap } = await this.split(mint, needed, await this.proofsAt(mint));
|
|
30515
30431
|
let preview;
|
|
@@ -30544,6 +30460,7 @@ var CashuWallet = class CashuWallet {
|
|
|
30544
30460
|
stores[STORES$1.melts].put(melt);
|
|
30545
30461
|
if (swap) stores[STORES$1.swaps].delete(swap.id);
|
|
30546
30462
|
});
|
|
30463
|
+
this.shownQuotes.delete(quoteId);
|
|
30547
30464
|
let result;
|
|
30548
30465
|
try {
|
|
30549
30466
|
result = await wallet.completeMelt(preview);
|
|
@@ -30716,7 +30633,7 @@ var CashuWallet = class CashuWallet {
|
|
|
30716
30633
|
let wallet = this.wallets.get(mint);
|
|
30717
30634
|
if (!wallet) {
|
|
30718
30635
|
wallet = (async () => {
|
|
30719
|
-
const instance = new Wallet
|
|
30636
|
+
const instance = new Wallet(mint, { unit: UNIT$1 });
|
|
30720
30637
|
await within(MINT_TIMEOUT_MS, instance.loadMint(), mint);
|
|
30721
30638
|
return instance;
|
|
30722
30639
|
})();
|
|
@@ -31343,7 +31260,7 @@ var PaymentDesk = class {
|
|
|
31343
31260
|
if (request.target) throw new Error("Review and explicitly approve this payment before sending");
|
|
31344
31261
|
if (request.state !== "pending") throw new Error("This request is no longer open");
|
|
31345
31262
|
if (request.lightningPending) throw new Error("A Lightning payment for this request is still pending");
|
|
31346
|
-
if ([...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "out" && p.requestId === request.id && p.state !== "reclaimed" && p.state !== "failed")) throw new Error("You already paid this request");
|
|
31263
|
+
if ([...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "out" && p.requestId === request.id && p.state !== "reclaimed" && (p.state !== "failed" || !!p.token))) throw new Error("You already paid this request");
|
|
31347
31264
|
assertConfirmedReal(network, params.confirmedReal);
|
|
31348
31265
|
const link = this.requireLink(params.linkId);
|
|
31349
31266
|
await link.requirePaymentSupport();
|
|
@@ -31509,10 +31426,13 @@ var PaymentDesk = class {
|
|
|
31509
31426
|
});
|
|
31510
31427
|
return { askId };
|
|
31511
31428
|
}
|
|
31512
|
-
/**
|
|
31513
|
-
|
|
31429
|
+
/**
|
|
31430
|
+
* The ask a request answers, only if this side made it: same chat, way of paying and amount, still fresh, and the
|
|
31431
|
+
* network the request is of (what it says, else what it carries), never only what it says.
|
|
31432
|
+
*/
|
|
31433
|
+
answering(linkId, request, method, amount, network) {
|
|
31514
31434
|
const ask = request.ask ? this.asks.get(request.ask) : void 0;
|
|
31515
|
-
if (!ask || ask.linkId !== linkId || ask.method !== method || ask.amount !== amount || ask.expiresAt < Date.now() ||
|
|
31435
|
+
if (!ask || ask.linkId !== linkId || ask.method !== method || ask.amount !== amount || ask.expiresAt < Date.now() || network !== ask.network) return void 0;
|
|
31516
31436
|
this.asks.delete(request.ask);
|
|
31517
31437
|
return request.ask;
|
|
31518
31438
|
}
|
|
@@ -31635,7 +31555,7 @@ var PaymentDesk = class {
|
|
|
31635
31555
|
await this.save({
|
|
31636
31556
|
target,
|
|
31637
31557
|
federations,
|
|
31638
|
-
ask: federations ? this.answering(linkId, request, "fedimint", amount) : target?.method === "arkade" || target?.method === "bark" || target?.method === "bitcoin" || target?.method === "spark" ? this.answering(linkId, request, target.method, amount) : void 0,
|
|
31558
|
+
ask: federations ? this.answering(linkId, request, "fedimint", amount, network) : target?.method === "arkade" || target?.method === "bark" || target?.method === "bitcoin" || target?.method === "spark" ? this.answering(linkId, request, target.method, amount, network) : void 0,
|
|
31639
31559
|
id: this.keyFor(linkId, request.id),
|
|
31640
31560
|
linkId,
|
|
31641
31561
|
kind: "request",
|
|
@@ -32061,7 +31981,7 @@ var PaymentDesk = class {
|
|
|
32061
31981
|
memo: request.memo,
|
|
32062
31982
|
state: "pending",
|
|
32063
31983
|
createdAt: request.timestamp,
|
|
32064
|
-
ask: this.answering(linkId, request, "usdt", amount),
|
|
31984
|
+
ask: this.answering(linkId, request, "usdt", amount, network),
|
|
32065
31985
|
network
|
|
32066
31986
|
});
|
|
32067
31987
|
await this.host.storeMessage({
|
|
@@ -32302,6 +32222,7 @@ var PaymentDesk = class {
|
|
|
32302
32222
|
}
|
|
32303
32223
|
if ([...this.payments.values()].some((p) => p.txid === txid && (p.linkId !== linkId || (p.kind === "request" ? p.id !== request.id : p.requestId !== request.id)))) return;
|
|
32304
32224
|
if (typeof txid !== "string" || !/^[a-f0-9]{64}$/.test(txid) || existing?.state === "settled") return;
|
|
32225
|
+
if (!existing && [...this.payments.values()].some((p) => p.kind === "payment" && p.direction === "in" && p.requestId === request.id && p.state === "pending")) return;
|
|
32305
32226
|
await this.save({
|
|
32306
32227
|
id: payment.id,
|
|
32307
32228
|
linkId,
|
|
@@ -32592,8 +32513,7 @@ var PaymentDesk = class {
|
|
|
32592
32513
|
for (const request of [...this.payments.values()]) {
|
|
32593
32514
|
if (request.kind !== "request" || request.direction !== "out" || request.state !== "pending" || request.target?.method !== "bitcoin") continue;
|
|
32594
32515
|
const claimed = new Set([...this.payments.values()].filter((p) => p.target?.method === "bitcoin" && p.kind === "request" && p.id !== request.id && p.txid).map((p) => p.txid));
|
|
32595
|
-
const
|
|
32596
|
-
const seen = await this.bitcoin[walletNetworkOf(request.target.network)].received(request.target, request.amount, claimed, receipt?.txid).catch(() => void 0);
|
|
32516
|
+
const seen = await this.bitcoin[walletNetworkOf(request.target.network)].received(request.target, request.amount, claimed).catch(() => void 0);
|
|
32597
32517
|
if (!seen || seen.confirmations < 1) continue;
|
|
32598
32518
|
await this.settleRequest(request, { txid: seen.txid });
|
|
32599
32519
|
for (const payment of this.payments.values()) if (payment.kind === "payment" && payment.direction === "in" && payment.requestId === request.id && payment.state === "pending") await this.save({
|
|
@@ -33444,7 +33364,7 @@ function pushRelayProblem(url) {
|
|
|
33444
33364
|
"localhost",
|
|
33445
33365
|
"[::1]"
|
|
33446
33366
|
].includes(parsed.hostname)) return null;
|
|
33447
|
-
return
|
|
33367
|
+
return `Use an https:// relay address: ${url}`;
|
|
33448
33368
|
}
|
|
33449
33369
|
//#endregion
|
|
33450
33370
|
//#region ../browser/src/engine/walletSetup.ts
|
|
@@ -33565,6 +33485,8 @@ var RELAY_RETRY_MS = 3e4;
|
|
|
33565
33485
|
var NATIVE_SLOTS = 8;
|
|
33566
33486
|
/** How often a group link that found no free native slot tries again. */
|
|
33567
33487
|
var GROUP_NATIVE_RETRY_MS = 15e3;
|
|
33488
|
+
/** The longest a group link whose native listener failed to start waits before trying again (its wait doubles up to it). */
|
|
33489
|
+
var GROUP_NATIVE_FAILED_MAX_MS = 3e5;
|
|
33568
33490
|
/** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
|
|
33569
33491
|
var CLEAR_WITHDRAW_MS = 6e3;
|
|
33570
33492
|
/**
|
|
@@ -33595,6 +33517,16 @@ var CARD_RESTORE_MOST = 50;
|
|
|
33595
33517
|
var CARD_RESTORE_MS = 6048e5;
|
|
33596
33518
|
/** A reaction said on the live session and not confirmed is said again after this long. */
|
|
33597
33519
|
var REACTION_RESEND_MS = 3e4;
|
|
33520
|
+
/** This side's participation key in a chat: derived from its seed once, not at every state built. */
|
|
33521
|
+
function participationKeyOf(live) {
|
|
33522
|
+
const seed = live.stored.participationSeed;
|
|
33523
|
+
if (!seed) return void 0;
|
|
33524
|
+
if (live.participationKey?.seed !== seed) live.participationKey = {
|
|
33525
|
+
seed,
|
|
33526
|
+
key: identityFromSeedB64(seed).pubKeyZ32
|
|
33527
|
+
};
|
|
33528
|
+
return live.participationKey.key;
|
|
33529
|
+
}
|
|
33598
33530
|
function emptyLinkFiles() {
|
|
33599
33531
|
return {
|
|
33600
33532
|
receivedBytes: 0,
|
|
@@ -33800,6 +33732,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
33800
33732
|
return Promise.all(this.nativeQueues.values()).then(() => {});
|
|
33801
33733
|
}
|
|
33802
33734
|
shuttingDown = false;
|
|
33735
|
+
/** Settles as `shutdown` begins: what is still starting is not waited for past it. */
|
|
33736
|
+
stopBegan;
|
|
33737
|
+
stopping = new Promise((resolve) => {
|
|
33738
|
+
this.stopBegan = () => resolve(null);
|
|
33739
|
+
});
|
|
33803
33740
|
/** `start` has put the kept transfers back (EngineState.transfersRestored). */
|
|
33804
33741
|
transfersRestored = false;
|
|
33805
33742
|
feedbackStartedAt = Date.now();
|
|
@@ -34388,10 +34325,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34388
34325
|
} : void 0;
|
|
34389
34326
|
},
|
|
34390
34327
|
keys: (linkId) => {
|
|
34391
|
-
const
|
|
34328
|
+
const live = this.links.get(linkId);
|
|
34392
34329
|
return {
|
|
34393
|
-
mine:
|
|
34394
|
-
theirs: stored
|
|
34330
|
+
mine: live && participationKeyOf(live),
|
|
34331
|
+
theirs: live?.stored.pairedPeerKey
|
|
34395
34332
|
};
|
|
34396
34333
|
},
|
|
34397
34334
|
linkIds: () => [...this.links.keys()],
|
|
@@ -34430,9 +34367,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34430
34367
|
emit: () => this.emitState(),
|
|
34431
34368
|
ownSubjects: () => this.identities.views().filter((p) => p.provider === "nostr").map((p) => p.subject),
|
|
34432
34369
|
contactSubjects: (linkId) => {
|
|
34433
|
-
const
|
|
34370
|
+
const live = this.links.get(linkId), stored = live?.stored;
|
|
34434
34371
|
if (!stored?.profile || !stored.identities) return [];
|
|
34435
|
-
const mine =
|
|
34372
|
+
const mine = participationKeyOf(live);
|
|
34436
34373
|
const t = Math.floor(Date.now() / 1e3);
|
|
34437
34374
|
return [...new Set(stored.identities.received.filter((r) => r.binding.provider === "nostr" && receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified").map((r) => r.verified.subject))];
|
|
34438
34375
|
},
|
|
@@ -34494,7 +34431,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34494
34431
|
for (const live of this.links.values()) {
|
|
34495
34432
|
const stored = live.stored;
|
|
34496
34433
|
if (!stored.profile || !stored.identities || stored.group) continue;
|
|
34497
|
-
const mine =
|
|
34434
|
+
const mine = participationKeyOf(live);
|
|
34498
34435
|
for (const r of stored.identities.received) if (receivedIdentityStatus(r, stored.pairedPeerKey, mine, t) === "verified") out.push({
|
|
34499
34436
|
provider: r.binding.provider,
|
|
34500
34437
|
subject: r.verified.subject,
|
|
@@ -34544,6 +34481,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34544
34481
|
patch: (chat, id, change) => db.patchMessage(chat, id, change),
|
|
34545
34482
|
changed: async (chat, id, note) => {
|
|
34546
34483
|
if (note) this.reactionNotes.set(chat, note);
|
|
34484
|
+
else if (this.reactionNotes.get(chat)?.message === id) {
|
|
34485
|
+
const before = latestReaction(await db.getMessages(chat));
|
|
34486
|
+
if (before) this.reactionNotes.set(chat, before);
|
|
34487
|
+
else this.reactionNotes.delete(chat);
|
|
34488
|
+
}
|
|
34547
34489
|
await this.messagesChanged(chat, [id]);
|
|
34548
34490
|
this.emitState();
|
|
34549
34491
|
},
|
|
@@ -34669,6 +34611,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34669
34611
|
*/
|
|
34670
34612
|
groupEdits = new GroupEdits({
|
|
34671
34613
|
messages: (chat) => db.getMessages(chat),
|
|
34614
|
+
history: (chat) => this.edgeOpenHistory(chat),
|
|
34672
34615
|
message: (chat, id) => db.getMessage(chat, id),
|
|
34673
34616
|
patch: (chat, id, change) => db.patchMessage(chat, id, change),
|
|
34674
34617
|
changed: (chat, id) => this.messagesChanged(chat, [id]),
|
|
@@ -34878,7 +34821,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
34878
34821
|
const awaiting = await this.readAwaiting();
|
|
34879
34822
|
for (const network of WALLET_NETWORKS) {
|
|
34880
34823
|
const view = await this.wallet.view(network);
|
|
34881
|
-
everything = view.history;
|
|
34824
|
+
everything = everything.concat(view.history);
|
|
34882
34825
|
const history = view.history.filter((tx) => !tx.mint || mintNetwork(tx.mint) === network);
|
|
34883
34826
|
networks[network] = {
|
|
34884
34827
|
mints: this.withWaits(view.mints, awaiting.mints),
|
|
@@ -35038,7 +34981,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35038
34981
|
}
|
|
35039
34982
|
this.transfersRestored = true;
|
|
35040
34983
|
for (const group of await db.getGroups()) await this.settleHistory(`group:${group.id}`);
|
|
35041
|
-
await this.groups.load();
|
|
34984
|
+
await this.groups.load(this.limitedMode);
|
|
35042
34985
|
for (const group of this.groups.views()) {
|
|
35043
34986
|
const note = latestReaction(await db.getMessages(`group:${group.id}`));
|
|
35044
34987
|
if (note) this.reactionNotes.set(`group:${group.id}`, note);
|
|
@@ -35438,12 +35381,17 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35438
35381
|
async shutdown(options = {}) {
|
|
35439
35382
|
const quiet = options.quiet === true || this.gatedOut;
|
|
35440
35383
|
this.shuttingDown = true;
|
|
35384
|
+
this.stopBegan();
|
|
35441
35385
|
if (this.limitedTimer) {
|
|
35442
35386
|
clearTimeout(this.limitedTimer);
|
|
35443
35387
|
this.limitedTimer = null;
|
|
35444
35388
|
}
|
|
35445
35389
|
this.appShelf?.stop();
|
|
35446
35390
|
if (!quiet) this.depart();
|
|
35391
|
+
const linksStopped = Promise.allSettled([...this.links.values()].map(async (live) => {
|
|
35392
|
+
live.carried?.stop();
|
|
35393
|
+
await Promise.all([live.link?.stop(!quiet), live.caps?.stop()]);
|
|
35394
|
+
}));
|
|
35447
35395
|
this.directPath.close();
|
|
35448
35396
|
this.clock.close();
|
|
35449
35397
|
this.clockOff?.();
|
|
@@ -35471,33 +35419,30 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35471
35419
|
this.activeTurnTimer = null;
|
|
35472
35420
|
if (this.restoreTimer) clearTimeout(this.restoreTimer);
|
|
35473
35421
|
this.restoreTimer = null;
|
|
35474
|
-
await links?.stop().catch(() => {});
|
|
35475
35422
|
this.identities.stop();
|
|
35476
35423
|
this.did.stop();
|
|
35477
35424
|
this.nostrSocial.stop();
|
|
35478
35425
|
this.publicProfiles.stop();
|
|
35479
35426
|
this.publicActivity.clear();
|
|
35480
|
-
for (const network of WALLET_NETWORKS) {
|
|
35481
|
-
await this.arkWallets[network].stop();
|
|
35482
|
-
await this.barkWallets[network].stop();
|
|
35483
|
-
await this.fedimintWallets[network].stop();
|
|
35484
|
-
await this.sparkWallets[network].stop();
|
|
35485
|
-
await this.usdtWallets[network].stop();
|
|
35486
|
-
await this.lightnings[network].stop();
|
|
35487
|
-
await this.bitcoins[network].stop();
|
|
35488
|
-
}
|
|
35489
|
-
await this.wallet.stop();
|
|
35490
|
-
await this.nativeQueue;
|
|
35491
|
-
await this.hold.stop();
|
|
35492
|
-
await Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop()));
|
|
35493
35427
|
for (const queue of this.editQueues.values()) queue.stop();
|
|
35494
35428
|
this.groupEdits.stop();
|
|
35495
35429
|
this.cardEdits.stop();
|
|
35496
|
-
await Promise.allSettled([
|
|
35497
|
-
|
|
35498
|
-
|
|
35499
|
-
|
|
35500
|
-
|
|
35430
|
+
await Promise.allSettled([
|
|
35431
|
+
linksStopped,
|
|
35432
|
+
links?.stop(),
|
|
35433
|
+
...WALLET_NETWORKS.flatMap((network) => [
|
|
35434
|
+
this.arkWallets,
|
|
35435
|
+
this.barkWallets,
|
|
35436
|
+
this.fedimintWallets,
|
|
35437
|
+
this.sparkWallets,
|
|
35438
|
+
this.usdtWallets,
|
|
35439
|
+
this.lightnings,
|
|
35440
|
+
this.bitcoins
|
|
35441
|
+
].map((wallets) => wallets[network].stop())),
|
|
35442
|
+
this.wallet.stop(),
|
|
35443
|
+
this.nativeQueue,
|
|
35444
|
+
Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop())).then(() => this.hold.stop())
|
|
35445
|
+
]);
|
|
35501
35446
|
}
|
|
35502
35447
|
/**
|
|
35503
35448
|
* "Clear all data" (WISP 04), the peer's part. First what can be taken back from the network, best effort and within
|
|
@@ -35518,7 +35463,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35518
35463
|
appShelf;
|
|
35519
35464
|
/** The installed apps and stores, refused while the apps feature is off. */
|
|
35520
35465
|
get appStore() {
|
|
35521
|
-
if (!this.appsOn) throw
|
|
35466
|
+
if (!this.appsOn) throw this.appsOff();
|
|
35522
35467
|
return this.appShelf ??= new Apps({
|
|
35523
35468
|
fetch: boundedAppFetch({
|
|
35524
35469
|
fetcher: this.options.appFetch,
|
|
@@ -35674,6 +35619,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
35674
35619
|
getMessages(linkId) {
|
|
35675
35620
|
return db.getMessages(linkId);
|
|
35676
35621
|
}
|
|
35622
|
+
/** One message of a chat, read by its id: the whole history costs a read of every row. */
|
|
35623
|
+
getMessage(linkId, id) {
|
|
35624
|
+
return db.getMessage(linkId, id);
|
|
35625
|
+
}
|
|
35677
35626
|
async messagePage({ linkId, limit = 50, before }) {
|
|
35678
35627
|
if (!Number.isSafeInteger(limit) || limit < 1) throw new Error("limit: a whole number of messages, at least 1");
|
|
35679
35628
|
if (before === void 0) return db.getMessagePage(linkId, { limit });
|
|
@@ -36438,6 +36387,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36438
36387
|
this.transport.networkChanged?.();
|
|
36439
36388
|
this.directPath.reset();
|
|
36440
36389
|
}
|
|
36390
|
+
if (params.network) for (const failed of this.groupNativeFailed.values()) {
|
|
36391
|
+
failed.at = 0;
|
|
36392
|
+
failed.wait = 0;
|
|
36393
|
+
}
|
|
36441
36394
|
for (const live of this.links.values()) live.link?.wake({ network: params.network });
|
|
36442
36395
|
this.hold.wake();
|
|
36443
36396
|
for (const network of WALLET_NETWORKS) {
|
|
@@ -36661,12 +36614,26 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36661
36614
|
/**
|
|
36662
36615
|
* A received reply, or one sent before its original was here, against this chat's own history: the original found
|
|
36663
36616
|
* here gives the line and the author (and it is then checked); one not found keeps what the wire said, unchecked.
|
|
36617
|
+
* The original is read alone where its id says its row (a group's message id, a chat's `peer_`/`me_` text): a reply
|
|
36618
|
+
* costs what a plain message does, however long the chat. A group's row goes by its own id only, so a miss there is
|
|
36619
|
+
* not here; a chat's other rows (a file's, a payment's) are looked for in the whole chat.
|
|
36664
36620
|
*/
|
|
36665
36621
|
async resolveReply(message) {
|
|
36666
36622
|
const reply = message.replyTo;
|
|
36667
36623
|
if (!reply || reply.messageId) return message;
|
|
36668
|
-
const
|
|
36669
|
-
|
|
36624
|
+
const group = message.linkId.startsWith("group:");
|
|
36625
|
+
let original, history;
|
|
36626
|
+
for (const rowId of group ? [reply.id] : [`peer_${reply.id}`, `me_${reply.id}`]) {
|
|
36627
|
+
const row = rowId === message.id ? void 0 : await db.getMessage(message.linkId, rowId);
|
|
36628
|
+
if (row && replyRef(row, group) === reply.id) {
|
|
36629
|
+
original = row;
|
|
36630
|
+
break;
|
|
36631
|
+
}
|
|
36632
|
+
}
|
|
36633
|
+
if (!original && !group) {
|
|
36634
|
+
history = await db.getMessages(message.linkId);
|
|
36635
|
+
original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
|
|
36636
|
+
}
|
|
36670
36637
|
if (!original) return message;
|
|
36671
36638
|
const resolved = {
|
|
36672
36639
|
...message,
|
|
@@ -36676,7 +36643,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36676
36643
|
...reply.button && { button: reply.button }
|
|
36677
36644
|
}
|
|
36678
36645
|
};
|
|
36679
|
-
const press = buttonPress(resolved, original, history);
|
|
36646
|
+
const press = buttonPress(resolved, original, []) && buttonPress(resolved, original, history ?? await db.getMessages(message.linkId));
|
|
36680
36647
|
return press ? {
|
|
36681
36648
|
...resolved,
|
|
36682
36649
|
press
|
|
@@ -36842,19 +36809,27 @@ var GhostlyNode = class GhostlyNode {
|
|
|
36842
36809
|
* message goes again as an edit of its card alone: the same text, so no version and no edit mark. Once per message (the
|
|
36843
36810
|
* row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later edit of the
|
|
36844
36811
|
* author's replaces it, the highest number winning). A contact whose app does not show the kind (it would drop the card
|
|
36845
|
-
* and mark the text edited) gets none: the row waits for one that does. Bounded: the newest
|
|
36846
|
-
*
|
|
36847
|
-
*
|
|
36812
|
+
* and mark the text edited) gets none: the row waits for one that does. Bounded: only the newest message of each card
|
|
36813
|
+
* (kind and id: a bot may send a task again as a new message), the newest `CARD_RESTORE_MOST` such cards of the last
|
|
36814
|
+
* `CARD_RESTORE_MS`; the others are given up. The edit queue spaces them (`CARD_RESTORE_GAP_MS`), so a reconnect does
|
|
36815
|
+
* not burst. The edit is marked `restore`: the bot's own event stream says nothing of it, while its number counts, so a
|
|
36816
|
+
* later edit of the bot's takes the next one.
|
|
36848
36817
|
*/
|
|
36849
36818
|
async restoreCards(linkId) {
|
|
36850
36819
|
const live = this.links.get(linkId), link = live?.link;
|
|
36851
36820
|
const offers = link?.sessionOffers.peer;
|
|
36852
36821
|
if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !offers) return;
|
|
36853
|
-
const
|
|
36822
|
+
const mine = (await db.getMessages(linkId)).filter((m) => m.sender === "me").sort((a, b) => b.timestamp - a.timestamp);
|
|
36854
36823
|
const since = Date.now() - CARD_RESTORE_MS;
|
|
36855
36824
|
const changed = [];
|
|
36856
|
-
|
|
36857
|
-
|
|
36825
|
+
const cards = /* @__PURE__ */ new Set();
|
|
36826
|
+
let kept = 0;
|
|
36827
|
+
for (const message of mine) {
|
|
36828
|
+
const key = message.card && `${message.card.kind}\n${message.card.id}`;
|
|
36829
|
+
const superseded = !!key && cards.has(key);
|
|
36830
|
+
if (key) cards.add(key);
|
|
36831
|
+
if ((message.cardRestore ?? message.buttonsRestore) !== "due") continue;
|
|
36832
|
+
const stale = superseded || kept++ >= CARD_RESTORE_MOST || message.timestamp < since;
|
|
36858
36833
|
if (!stale && message.card && !offers.includes(GhostlyNode.cardCapability(message.card))) continue;
|
|
36859
36834
|
const at = Date.now();
|
|
36860
36835
|
if ((await db.patchMessage(linkId, message.id, (current) => {
|
|
@@ -37045,7 +37020,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
37045
37020
|
return composeDetails(message, {
|
|
37046
37021
|
link: live && stored && {
|
|
37047
37022
|
stored,
|
|
37048
|
-
myKey:
|
|
37023
|
+
myKey: participationKeyOf(live) ?? live.myPubKeyZ32,
|
|
37049
37024
|
verified: !!stored.pairedPeerKey && (stored.peerTrust ? stored.peerTrust.verifiedKey === stored.pairedPeerKey : true),
|
|
37050
37025
|
transportNow: live.link?.isDataLinkOpen ? live.pairing?.transport : void 0,
|
|
37051
37026
|
relayedNow: !!live.link?.relayedPath,
|
|
@@ -37856,11 +37831,23 @@ var GhostlyNode = class GhostlyNode {
|
|
|
37856
37831
|
/**
|
|
37857
37832
|
* `chosen`: a choice from the chat's Connection menu, a row in its timeline even when it names the transport already
|
|
37858
37833
|
* set. Left out (the RPC), only a change of transport is: the Fallback switch sends the same preference again, and
|
|
37859
|
-
* is neither a row nor a switch intent.
|
|
37834
|
+
* is neither a row nor a switch intent. On Automatic it sends back the transport Automatic names: only the fallback
|
|
37835
|
+
* changes, and the chat stays on Automatic.
|
|
37860
37836
|
*/
|
|
37861
37837
|
async setTransportPreference({ linkId, preferred, fallback }, chosen) {
|
|
37862
37838
|
const live = this.links.get(linkId);
|
|
37863
37839
|
if (!live?.stored.profile || !live.link?.availableTransports.includes(preferred) || typeof fallback !== "boolean") throw new Error("Transport unavailable");
|
|
37840
|
+
if (chosen === void 0 && live.stored.preferredTransport === void 0 && preferred === automaticTransport(live.link.availableTransports)) {
|
|
37841
|
+
await db.patchLink(linkId, { transportFallback: fallback });
|
|
37842
|
+
live.stored = {
|
|
37843
|
+
...live.stored,
|
|
37844
|
+
transportFallback: fallback
|
|
37845
|
+
};
|
|
37846
|
+
this.emitState();
|
|
37847
|
+
await live.link.setTransportPreference(preferred, fallback, true, false);
|
|
37848
|
+
this.capsChanged(linkId);
|
|
37849
|
+
return;
|
|
37850
|
+
}
|
|
37864
37851
|
chosen ??= live.stored.preferredTransport !== preferred;
|
|
37865
37852
|
const patch = {
|
|
37866
37853
|
preferredTransport: preferred,
|
|
@@ -38107,6 +38094,18 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38107
38094
|
f: reply.member
|
|
38108
38095
|
}, readForwarded(hops));
|
|
38109
38096
|
}
|
|
38097
|
+
/**
|
|
38098
|
+
* Whether `sendGroupFile` would take a file of mine for the group now, asked before its bytes are copied in: the
|
|
38099
|
+
* error it would answer for the group's pace of files or for not being in it, or null. Not a promise: it may still
|
|
38100
|
+
* refuse (the minute's files can fill in between).
|
|
38101
|
+
*/
|
|
38102
|
+
groupFileCheck({ groupId }) {
|
|
38103
|
+
if (typeof groupId !== "string") return {
|
|
38104
|
+
error: "No such group",
|
|
38105
|
+
refused: true
|
|
38106
|
+
};
|
|
38107
|
+
return this.groups.fileCheck(groupId);
|
|
38108
|
+
}
|
|
38110
38109
|
groupMessages({ groupId }) {
|
|
38111
38110
|
return this.groups.messages(groupId);
|
|
38112
38111
|
}
|
|
@@ -38394,19 +38393,47 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38394
38393
|
} catch {}
|
|
38395
38394
|
return { error: null };
|
|
38396
38395
|
}
|
|
38397
|
-
/**
|
|
38396
|
+
/** A group's history read in flight for edges that opened, by chat: its callers of the moment share it. */
|
|
38397
|
+
edgeOpenReads = /* @__PURE__ */ new Map();
|
|
38398
|
+
/**
|
|
38399
|
+
* A group's whole history, for what an edge that opens hears again (my latest edits and reactions): the edges that
|
|
38400
|
+
* open together, a member back in a group of 8 opening 7, read it once, not twice each. Ended, the next one reads anew.
|
|
38401
|
+
*/
|
|
38402
|
+
edgeOpenHistory(chat) {
|
|
38403
|
+
let read = this.edgeOpenReads.get(chat);
|
|
38404
|
+
if (!read) {
|
|
38405
|
+
read = db.getMessages(chat).finally(() => this.edgeOpenReads.delete(chat));
|
|
38406
|
+
this.edgeOpenReads.set(chat, read);
|
|
38407
|
+
}
|
|
38408
|
+
return read;
|
|
38409
|
+
}
|
|
38410
|
+
/**
|
|
38411
|
+
* An edge of a private group opened: the member hears my latest reactions again, in case it missed them. The newest
|
|
38412
|
+
* `REACTION_LIMITS.send` go now and the rest a window later, while the edge is the same: the member takes 30 a window
|
|
38413
|
+
* from me, and 32 at once lost two of them.
|
|
38414
|
+
*/
|
|
38398
38415
|
async resendGroupReactions(groupId, linkId) {
|
|
38399
38416
|
if (this.outsideEdge(linkId)) return;
|
|
38400
|
-
const mine = groupReactionsToResend(await
|
|
38401
|
-
|
|
38402
|
-
|
|
38403
|
-
|
|
38404
|
-
|
|
38405
|
-
|
|
38406
|
-
|
|
38407
|
-
|
|
38408
|
-
|
|
38409
|
-
|
|
38417
|
+
const mine = groupReactionsToResend(await this.edgeOpenHistory(`group:${groupId}`), REACTION_LIMITS.pending);
|
|
38418
|
+
const link = this.links.get(linkId)?.link;
|
|
38419
|
+
const say = (batch) => {
|
|
38420
|
+
for (const reaction of batch) try {
|
|
38421
|
+
link?.sendGroupFrame({
|
|
38422
|
+
t: GROUP_REACTION_FRAME,
|
|
38423
|
+
g: groupId,
|
|
38424
|
+
...reaction,
|
|
38425
|
+
...this.groups.signReaction(groupId, reaction)
|
|
38426
|
+
});
|
|
38427
|
+
} catch {
|
|
38428
|
+
return;
|
|
38429
|
+
}
|
|
38430
|
+
};
|
|
38431
|
+
say(mine.slice(0, REACTION_LIMITS.send));
|
|
38432
|
+
for (let start = REACTION_LIMITS.send, turn = 1; start < mine.length; start += REACTION_LIMITS.send, turn++) {
|
|
38433
|
+
const batch = mine.slice(start, start + REACTION_LIMITS.send);
|
|
38434
|
+
setTimeout(() => {
|
|
38435
|
+
if (link && this.links.get(linkId)?.link === link) say(batch);
|
|
38436
|
+
}, turn * (REACTION_LIMITS.windowMs + 1e3));
|
|
38410
38437
|
}
|
|
38411
38438
|
}
|
|
38412
38439
|
/**
|
|
@@ -38473,11 +38500,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
38473
38500
|
}
|
|
38474
38501
|
/** Mini-apps are on in this build (`APPS_ENABLED`) or for this engine (`apps`). */
|
|
38475
38502
|
get appsOn() {
|
|
38476
|
-
return this.options.apps ??
|
|
38503
|
+
return this.options.apps ?? true;
|
|
38504
|
+
}
|
|
38505
|
+
/** Why an app call is refused: this client runs no mini-app (`apps: false`), or the build has them off. */
|
|
38506
|
+
appsOff() {
|
|
38507
|
+
return /* @__PURE__ */ new Error(this.options.apps === false ? "Apps do not run on this client" : "Apps are unavailable in this release");
|
|
38477
38508
|
}
|
|
38478
38509
|
/** A paired 1:1 chat and the chat app id of `ref` in it, from the two pinned participation keys (WISP 1200 § In a chat). */
|
|
38479
38510
|
appChat(linkId, ref) {
|
|
38480
|
-
if (!this.appsOn) throw
|
|
38511
|
+
if (!this.appsOn) throw this.appsOff();
|
|
38481
38512
|
const live = typeof linkId === "string" ? this.links.get(linkId) : void 0;
|
|
38482
38513
|
if (!live || live.stored.group || !live.stored.profile) throw new Error("No such chat");
|
|
38483
38514
|
if (!isAppRef(ref)) throw new Error("Not an app reference");
|
|
@@ -39420,7 +39451,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
39420
39451
|
if (params.requestId) {
|
|
39421
39452
|
if (!request || request.linkId !== params.linkId || request.direction !== "in" || request.state !== "pending" || request.amount !== params.amount) throw new Error("Payment review does not match the authenticated request");
|
|
39422
39453
|
if (params.target.method !== "cashu" ? JSON.stringify(request.target) !== JSON.stringify(params.target) : !!request.target || params.target.address !== request.id || !request.mints?.includes(params.target.provider)) throw new Error("Selected method or mint does not match the authenticated request");
|
|
39423
|
-
if (request.lightningPending ||
|
|
39454
|
+
if (request.lightningPending || this.desk.records().some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && p.state !== "reclaimed" && (p.state !== "failed" || !!p.token))) throw engineError("requestPaymentInFlight");
|
|
39424
39455
|
const asked = paymentNetwork(request);
|
|
39425
39456
|
if (asked !== paying) throw new Error(crossNetwork(paying, asked));
|
|
39426
39457
|
} else if (params.target.method !== "cashu" || !payee || params.target.address !== payee) throw new Error("Destination does not match the authenticated peer");
|
|
@@ -40562,6 +40593,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
40562
40593
|
this.groupWakeReceived.delete(linkId);
|
|
40563
40594
|
this.quietEdges.delete(linkId);
|
|
40564
40595
|
this.groupNativeWaiting.delete(linkId);
|
|
40596
|
+
this.groupNativeFailed.delete(linkId);
|
|
40565
40597
|
this.groupNativeRetryAt = 0;
|
|
40566
40598
|
if (live.stored.wakeToken && this.meshEdge(live.stored)) {
|
|
40567
40599
|
const group = live.stored.group, peer = live.stored.groupPeer;
|
|
@@ -40730,6 +40762,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
40730
40762
|
automatic: true
|
|
40731
40763
|
},
|
|
40732
40764
|
packetTransports: !this.options.webrtcGroupLinks,
|
|
40765
|
+
packetTransportsSaid: !!stored.transportSeeds && Object.keys(stored.transportSeeds).length > 0,
|
|
40733
40766
|
resumeFloor: !entry && stored.edgeLive ? stored.edgeLiveSince : void 0,
|
|
40734
40767
|
pairing: {
|
|
40735
40768
|
credentials: {
|
|
@@ -40790,6 +40823,21 @@ var GhostlyNode = class GhostlyNode {
|
|
|
40790
40823
|
};
|
|
40791
40824
|
db.patchLink(linkId, patch).catch(() => {});
|
|
40792
40825
|
if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
|
|
40826
|
+
else this.groupNativeWaiting.delete(linkId);
|
|
40827
|
+
},
|
|
40828
|
+
onPacketTransportsGone: () => {
|
|
40829
|
+
const patch = {
|
|
40830
|
+
peerTransports: void 0,
|
|
40831
|
+
peerDescriptors: void 0,
|
|
40832
|
+
peerFallback: void 0
|
|
40833
|
+
};
|
|
40834
|
+
live.stored = {
|
|
40835
|
+
...live.stored,
|
|
40836
|
+
...patch
|
|
40837
|
+
};
|
|
40838
|
+
db.patchLink(linkId, patch).catch(() => {});
|
|
40839
|
+
if (!this.keepsGroupNative(live.stored)) this.groupNativeWaiting.delete(linkId);
|
|
40840
|
+
this.emitState();
|
|
40793
40841
|
},
|
|
40794
40842
|
...entry ? {} : {
|
|
40795
40843
|
onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
|
|
@@ -40832,12 +40880,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
40832
40880
|
this.emitState();
|
|
40833
40881
|
},
|
|
40834
40882
|
onStatus: (status) => {
|
|
40835
|
-
live.status
|
|
40836
|
-
|
|
40883
|
+
if (status !== live.status) {
|
|
40884
|
+
live.status = status;
|
|
40885
|
+
this.emitState();
|
|
40886
|
+
}
|
|
40837
40887
|
},
|
|
40838
40888
|
onDiscoveryError: (error) => {
|
|
40839
|
-
|
|
40840
|
-
|
|
40889
|
+
if ((error ?? void 0) !== live.discoveryError) {
|
|
40890
|
+
live.discoveryError = error ?? void 0;
|
|
40891
|
+
this.emitState();
|
|
40892
|
+
}
|
|
40841
40893
|
}
|
|
40842
40894
|
}
|
|
40843
40895
|
});
|
|
@@ -40889,6 +40941,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
40889
40941
|
expectedPeerKey: stored.peerParticipationKeyZ32
|
|
40890
40942
|
} : void 0;
|
|
40891
40943
|
const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? resumeHere(this.transportLogOf(live), this.transportsHere(), stored.peerTransports ?? live.caps?.peer?.transports) : {};
|
|
40944
|
+
let dhtShown;
|
|
40892
40945
|
live.link = new GhostLink({
|
|
40893
40946
|
paymentMethods: stored.paymentMethods,
|
|
40894
40947
|
paymentNetworks: this.chatNetworks(stored),
|
|
@@ -41012,10 +41065,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41012
41065
|
},
|
|
41013
41066
|
onGroupFrame: stored.profile ? (frame) => this.groups.handleContactFrame(linkId, frame) : void 0,
|
|
41014
41067
|
onGroupsSupport: () => this.emitState(),
|
|
41015
|
-
onDhtDelivery: () => {
|
|
41068
|
+
onDhtDelivery: (view) => {
|
|
41016
41069
|
if (stored.profile && !stored.group) this.outboxes.get(linkId)?.flush().catch(() => {}).then(() => this.editQueues.get(linkId)?.flush()).catch(() => {});
|
|
41017
41070
|
this.observeTransport(linkId);
|
|
41018
|
-
|
|
41071
|
+
const shown = JSON.stringify(view);
|
|
41072
|
+
if (shown !== dhtShown) {
|
|
41073
|
+
dhtShown = shown;
|
|
41074
|
+
this.emitState();
|
|
41075
|
+
}
|
|
41019
41076
|
},
|
|
41020
41077
|
onHold: (state) => this.hold.peerSaid(linkId, state),
|
|
41021
41078
|
onPeerProof: void 0,
|
|
@@ -41053,8 +41110,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41053
41110
|
else this.emitState();
|
|
41054
41111
|
},
|
|
41055
41112
|
onDiscoveryError: (error) => {
|
|
41056
|
-
|
|
41057
|
-
|
|
41113
|
+
if ((error ?? void 0) !== live.discoveryError) {
|
|
41114
|
+
live.discoveryError = error ?? void 0;
|
|
41115
|
+
this.emitState();
|
|
41116
|
+
}
|
|
41058
41117
|
},
|
|
41059
41118
|
onTransportDiscovery: async (peerDescriptors, peerTransports, peerFallback) => {
|
|
41060
41119
|
const patch = {
|
|
@@ -41079,23 +41138,27 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41079
41138
|
this.emitState();
|
|
41080
41139
|
},
|
|
41081
41140
|
onStatus: (status) => {
|
|
41141
|
+
const was = live.status, synced = live.lastSyncAt;
|
|
41082
41142
|
live.status = status;
|
|
41083
41143
|
if (status === "online") live.lastSyncAt = Date.now();
|
|
41084
|
-
this.emitState();
|
|
41144
|
+
if (status !== was || !synced || stored.group || linkId === this.activeLinkId) this.emitState();
|
|
41085
41145
|
},
|
|
41086
41146
|
onPoll: ({ polling, nextInMs }) => {
|
|
41147
|
+
const was = live.poll.polling;
|
|
41087
41148
|
live.poll = {
|
|
41088
41149
|
polling,
|
|
41089
41150
|
nextAt: Date.now() + nextInMs,
|
|
41090
41151
|
interval: nextInMs || live.poll.interval
|
|
41091
41152
|
};
|
|
41092
|
-
this.emitState();
|
|
41153
|
+
if (linkId === this.activeLinkId || !live.lastSyncAt && polling !== was) this.emitState();
|
|
41093
41154
|
},
|
|
41094
41155
|
onMessageReceipt: async (id) => {
|
|
41095
|
-
|
|
41156
|
+
const oneToOne = !!stored.profile && !stored.group;
|
|
41157
|
+
const mine = oneToOne ? await db.getMessage(linkId, `me_${id}`) : void 0;
|
|
41158
|
+
if (oneToOne && !mine) await this.fileSeenOnFloor(linkId, id);
|
|
41096
41159
|
await this.outboxFor(linkId).received(id);
|
|
41097
|
-
if (
|
|
41098
|
-
if (
|
|
41160
|
+
if (oneToOne && !mine) await this.editsFor(linkId).receivedOnDht(id).catch(() => {});
|
|
41161
|
+
if (oneToOne && (!mine || mine.edit?.pending)) this.editsFor(linkId).flush().catch(() => {});
|
|
41099
41162
|
},
|
|
41100
41163
|
onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
|
|
41101
41164
|
onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
|
|
@@ -41288,7 +41351,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41288
41351
|
}
|
|
41289
41352
|
}
|
|
41290
41353
|
nativeUp?.then(() => {
|
|
41291
|
-
if (live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
|
|
41354
|
+
if (!this.shuttingDown && live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
|
|
41292
41355
|
});
|
|
41293
41356
|
}
|
|
41294
41357
|
/**
|
|
@@ -41368,7 +41431,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41368
41431
|
get nativeFactories() {
|
|
41369
41432
|
const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
|
|
41370
41433
|
return {
|
|
41371
|
-
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-
|
|
41434
|
+
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-DmTrpmba.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
|
|
41372
41435
|
...this.options.nativeTransports,
|
|
41373
41436
|
...this.ownIrohRelays ? { "iroh/1": (seedB64) => this.options.nativeTransports["iroh/1"](seedB64, this.settings.irohRelays?.length ? { relays: [...this.settings.irohRelays] } : {}) } : {},
|
|
41374
41437
|
...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
|
|
@@ -41470,13 +41533,23 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41470
41533
|
const live = this.links.get(linkId), link = live?.link;
|
|
41471
41534
|
if (this.shuttingDown || !live || !link || link !== expected) return;
|
|
41472
41535
|
if (live.stored.group && this.keepsGroupNative(live.stored)) {
|
|
41473
|
-
if (full.
|
|
41536
|
+
if (full.includes("no-slot") && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
|
|
41474
41537
|
else this.groupNativeWaiting.delete(linkId);
|
|
41475
|
-
|
|
41538
|
+
if (full.includes("failed")) {
|
|
41539
|
+
const wait = Math.min(Math.max(GROUP_NATIVE_RETRY_MS, (this.groupNativeFailed.get(linkId)?.wait ?? 0) * 2), GROUP_NATIVE_FAILED_MAX_MS);
|
|
41540
|
+
this.groupNativeFailed.set(linkId, {
|
|
41541
|
+
at: Date.now() + wait,
|
|
41542
|
+
wait
|
|
41543
|
+
});
|
|
41544
|
+
} else this.groupNativeFailed.delete(linkId);
|
|
41545
|
+
} else this.groupNativeFailed.delete(linkId);
|
|
41476
41546
|
this.emitState();
|
|
41477
41547
|
});
|
|
41478
41548
|
}
|
|
41479
|
-
/**
|
|
41549
|
+
/**
|
|
41550
|
+
* One transport's listener for one chat, in that transport's queue. "no-slot" when a group's link found no slot free,
|
|
41551
|
+
* "failed" when the listener could not start.
|
|
41552
|
+
*/
|
|
41480
41553
|
async startNativeEndpoint(linkId, expected, key, factory, inUse) {
|
|
41481
41554
|
const live = this.links.get(linkId), link = live?.link;
|
|
41482
41555
|
if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return false;
|
|
@@ -41486,7 +41559,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41486
41559
|
live.transportErrors ??= {};
|
|
41487
41560
|
try {
|
|
41488
41561
|
const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
|
|
41489
|
-
if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return
|
|
41562
|
+
if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) return "no-slot";
|
|
41490
41563
|
if (owners.length >= NATIVE_SLOTS) {
|
|
41491
41564
|
const now = Date.now();
|
|
41492
41565
|
const taking = !group && (this.activeLinkId === linkId || inUse);
|
|
@@ -41530,7 +41603,12 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41530
41603
|
transportSeeds
|
|
41531
41604
|
};
|
|
41532
41605
|
await db.patchLink(linkId, { transportSeeds });
|
|
41533
|
-
const
|
|
41606
|
+
const starting = factory(seed);
|
|
41607
|
+
const endpoint = await Promise.race([starting, this.stopping]);
|
|
41608
|
+
if (!endpoint) {
|
|
41609
|
+
starting.then((late) => late.close(), () => {});
|
|
41610
|
+
return false;
|
|
41611
|
+
}
|
|
41534
41612
|
if (this.shuttingDown || live.stored.deliveryMode === "dht" || live.link !== link || !this.links.has(linkId)) {
|
|
41535
41613
|
await endpoint.close();
|
|
41536
41614
|
return false;
|
|
@@ -41541,6 +41619,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41541
41619
|
this.emitState();
|
|
41542
41620
|
} catch (error) {
|
|
41543
41621
|
live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
|
|
41622
|
+
return "failed";
|
|
41544
41623
|
}
|
|
41545
41624
|
return false;
|
|
41546
41625
|
}
|
|
@@ -41611,8 +41690,23 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41611
41690
|
/** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
|
|
41612
41691
|
groupNativeWaiting = /* @__PURE__ */ new Set();
|
|
41613
41692
|
groupNativeRetryAt = 0;
|
|
41614
|
-
/**
|
|
41693
|
+
/** Group links whose native listener failed to start, by link id: when each tries again, and how long it waited last. */
|
|
41694
|
+
groupNativeFailed = /* @__PURE__ */ new Map();
|
|
41695
|
+
/**
|
|
41696
|
+
* Now and then, the group links that found no free native slot try again: a chat or another group may have let one go.
|
|
41697
|
+
* Those whose listener failed to start try again once their wait is over.
|
|
41698
|
+
*/
|
|
41615
41699
|
retryGroupNative() {
|
|
41700
|
+
const now = Date.now();
|
|
41701
|
+
for (const [linkId, failed] of this.groupNativeFailed) {
|
|
41702
|
+
if (now < failed.at) continue;
|
|
41703
|
+
if (!this.links.get(linkId)?.link) {
|
|
41704
|
+
this.groupNativeFailed.delete(linkId);
|
|
41705
|
+
continue;
|
|
41706
|
+
}
|
|
41707
|
+
failed.at = now + failed.wait;
|
|
41708
|
+
this.ensureNativeEndpoints(linkId);
|
|
41709
|
+
}
|
|
41616
41710
|
if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
|
|
41617
41711
|
this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
|
|
41618
41712
|
for (const linkId of [...this.groupNativeWaiting]) {
|
|
@@ -41813,7 +41907,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
41813
41907
|
},
|
|
41814
41908
|
...this.reactionNotes.has(stored.id) && { lastReaction: this.reactionNotes.get(stored.id) },
|
|
41815
41909
|
...stored.pin && { pin: pinView(stored.pin) },
|
|
41816
|
-
participationKey:
|
|
41910
|
+
participationKey: participationKeyOf(live),
|
|
41817
41911
|
peerParticipationKey: stored.pairedPeerKey,
|
|
41818
41912
|
publicProfiles: void 0,
|
|
41819
41913
|
profileChoice: void 0,
|
|
@@ -41930,6 +42024,9 @@ var EngineServer = class {
|
|
|
41930
42024
|
* call (WISP 06 § States and events). A client that goes away (a tab closed mid-call) is taken out with it.
|
|
41931
42025
|
*/
|
|
41932
42026
|
onCall = /* @__PURE__ */ new Set();
|
|
42027
|
+
/** How many history changes the engine told; per chat or group, the count at its last one (`historyMark`). */
|
|
42028
|
+
changes = 0;
|
|
42029
|
+
changedAt = /* @__PURE__ */ new Map();
|
|
41933
42030
|
constructor(options = {}) {
|
|
41934
42031
|
this.node = new GhostlyNode({
|
|
41935
42032
|
onAttention: (event) => this.broadcast({
|
|
@@ -41940,8 +42037,14 @@ var EngineServer = class {
|
|
|
41940
42037
|
kind: "state",
|
|
41941
42038
|
state
|
|
41942
42039
|
}),
|
|
41943
|
-
onMessages: (linkId, messages) =>
|
|
41944
|
-
|
|
42040
|
+
onMessages: (linkId, messages) => {
|
|
42041
|
+
this.changed(linkId);
|
|
42042
|
+
this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {});
|
|
42043
|
+
},
|
|
42044
|
+
onMessageChanges: (linkId, changes) => {
|
|
42045
|
+
this.changed(linkId);
|
|
42046
|
+
this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {});
|
|
42047
|
+
},
|
|
41945
42048
|
onCallSignal: (linkId, signal) => {
|
|
41946
42049
|
if (signalKind(signal) === "o") this.offers.set(linkId, signal);
|
|
41947
42050
|
else this.offers.delete(linkId);
|
|
@@ -41965,9 +42068,21 @@ var EngineServer = class {
|
|
|
41965
42068
|
this.ready = this.node.start();
|
|
41966
42069
|
this.ready.catch(() => {});
|
|
41967
42070
|
}
|
|
41968
|
-
attach
|
|
42071
|
+
/** A point in the engine's history changes: a history read after it can be handed to `attach` as read. */
|
|
42072
|
+
get historyMark() {
|
|
42073
|
+
return this.changes;
|
|
42074
|
+
}
|
|
42075
|
+
/**
|
|
42076
|
+
* `read`: the chats and groups whose whole history the client read itself, after `mark` (`historyMark`). One that has
|
|
42077
|
+
* not changed since is not read and sent again: the client hears only what changes in it from now on.
|
|
42078
|
+
*/
|
|
42079
|
+
attach(client, read) {
|
|
41969
42080
|
this.clients.add(client);
|
|
41970
|
-
|
|
42081
|
+
const sent = /* @__PURE__ */ new Set();
|
|
42082
|
+
if (read) {
|
|
42083
|
+
for (const id of read.ids) if ((this.changedAt.get(id) ?? 0) <= read.mark) sent.add(id);
|
|
42084
|
+
}
|
|
42085
|
+
this.histories.set(client, sent);
|
|
41971
42086
|
this.ready.then(async () => {
|
|
41972
42087
|
if (!this.clients.has(client)) return;
|
|
41973
42088
|
client.post({
|
|
@@ -41980,15 +42095,18 @@ var EngineServer = class {
|
|
|
41980
42095
|
signal
|
|
41981
42096
|
});
|
|
41982
42097
|
else this.offers.delete(linkId);
|
|
41983
|
-
for (const link of this.node.getState().links)
|
|
41984
|
-
if (
|
|
41985
|
-
|
|
41986
|
-
|
|
41987
|
-
|
|
41988
|
-
|
|
42098
|
+
for (const link of this.node.getState().links) {
|
|
42099
|
+
if (sent.has(link.id)) continue;
|
|
42100
|
+
await this.inOrder(link.id, async () => {
|
|
42101
|
+
if (!this.clients.has(client)) return;
|
|
42102
|
+
client.post({
|
|
42103
|
+
kind: "messages",
|
|
42104
|
+
linkId: link.id,
|
|
42105
|
+
messages: await this.node.getMessages(link.id)
|
|
42106
|
+
});
|
|
42107
|
+
this.histories.get(client)?.add(link.id);
|
|
41989
42108
|
});
|
|
41990
|
-
|
|
41991
|
-
});
|
|
42109
|
+
}
|
|
41992
42110
|
}, (error) => {
|
|
41993
42111
|
this.post(client, {
|
|
41994
42112
|
kind: "start-failed",
|
|
@@ -42050,6 +42168,9 @@ var EngineServer = class {
|
|
|
42050
42168
|
return Promise.reject(error);
|
|
42051
42169
|
}
|
|
42052
42170
|
}
|
|
42171
|
+
changed(linkId) {
|
|
42172
|
+
this.changedAt.set(linkId, ++this.changes);
|
|
42173
|
+
}
|
|
42053
42174
|
track(linkId, running) {
|
|
42054
42175
|
const settled = running.catch(() => {});
|
|
42055
42176
|
this.sending.set(linkId, settled);
|
|
@@ -42115,4 +42236,4 @@ function signalKind(signal) {
|
|
|
42115
42236
|
//#endregion
|
|
42116
42237
|
export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
|
|
42117
42238
|
|
|
42118
|
-
//# sourceMappingURL=server-
|
|
42239
|
+
//# sourceMappingURL=server-BGXmSYx_.js.map
|