@ghostlytools/cli 1.0.2 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/assets/bark_ffi_wasm_bg-EY2uc0Bx.js.map +1 -1
- package/dist/assets/bitcoindevkit_bg-B-iLWrmX.js.map +1 -1
- package/dist/assets/fileBytes-Br_MpeNT.js +123 -0
- package/dist/assets/fileBytes-Br_MpeNT.js.map +1 -0
- package/dist/assets/{fileBytes-CLv0gryb.js → fileBytes-CpP9Vj7s.js} +2 -2
- package/dist/assets/{fileBytes-CLv0gryb.js.map → fileBytes-CpP9Vj7s.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-CKCtzMTy.js → fileBytesIdb-vubSXPoO.js} +3 -2
- package/dist/assets/{fileBytesIdb-CKCtzMTy.js.map → fileBytesIdb-vubSXPoO.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-By55XVTI.js → fileBytesOpfs-6iSa6u2K.js} +20 -8
- package/dist/assets/fileBytesOpfs-6iSa6u2K.js.map +1 -0
- package/dist/assets/{hyperdhtRelay-cMlRWrmD.js → hyperdhtRelay-VM9STKLd.js} +3 -3
- package/dist/assets/{hyperdhtRelay-cMlRWrmD.js.map → hyperdhtRelay-VM9STKLd.js.map} +1 -1
- package/dist/assets/idb-DIN_Miwb.js +351 -0
- package/dist/assets/idb-DIN_Miwb.js.map +1 -0
- package/dist/assets/{mainline-_roUnpDA.js → mainline-CcO2f8jy.js} +17 -10
- package/dist/assets/mainline-CcO2f8jy.js.map +1 -0
- package/dist/assets/restoredRows-tVxgyG23.js +79 -0
- package/dist/assets/restoredRows-tVxgyG23.js.map +1 -0
- package/dist/assets/{server-Cajp8VLY.js → server-BnNXYzR9.js} +1756 -202
- package/dist/assets/server-BnNXYzR9.js.map +1 -0
- package/dist/assets/src-7sdQpCoi.js +23665 -0
- package/dist/assets/src-7sdQpCoi.js.map +1 -0
- package/dist/ghostly.mjs +905 -23623
- package/dist/ghostly.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/assets/fileBytesOpfs-By55XVTI.js.map +0 -1
- package/dist/assets/mainline-_roUnpDA.js.map +0 -1
- package/dist/assets/server-Cajp8VLY.js.map +0 -1
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { a as fileBytesOf, c as removeFileBytes, d as __exportAll, i as fileBytes, n as blobDigest } from "./fileBytes-Br_MpeNT.js";
|
|
2
|
+
import { $a as createIdentity, $i as heardTime, $n as newerHead, $r as revokesIdentity, $t as isBitcoinAddress, Aa as LEGACY_SERVICES, Ai as GROUP_REACTION_FRAME, An as cashuRequestPayload, Ar as EDIT_CAPABILITY, At as didDhtFromPublicKey, Ba as currentRelays, Bi as pairedReplyAuthor, Bn as MESH_HUBS, Br as capsDescriptors, Bt as SEPOLIA_TEST_USDT_FAUCET, C as groupWakeFrame, Ci as EDIT_RATE_WINDOW_MS, Cr as WakeLimiter, D as GROUP_READ_NOTE, Di as forwardedAgain, Dn as engineText, Dr as checkPushEndpoint, Ea as parseCallSignal, Ei as parseLinkPreview, En as engineError, Er as wakeRequest, F as mentionsMember, Fa as encodeInviteCode, Fi as reactionEmoji, Fn as parseFedimintRequestPayload, Fr as HoldRefusedError, Ft as ETHEREUM_USDT, Ga as isDiscoveryBudgetError, Gn as beaconKeys, Gt as assertWholeSats, Hi as replySnippet, Hn as meshHubs, Ht as USDT_PUBLIC_RPC, I as groupName, Ii as reactionIsNewer, In as GhostLink, Ir as newMailbox, It as EVM_TEST_CHAINS, Ji as checkStatusCard, Jn as freshHubs, Jr as emptyIdentityLedger, Jt as walletNetworkOf, Ka as withRequestOptions, Ki as STATUS_CARD_LIMITS, Kn as beaconRecords, Kr as IdentityExchange, Kt as formatPaymentAmount, La as inviteOwnership, Li as readReaction, Ln as INVITE_TAKEN, Lr as readManifest, Lt as ONCHAIN_PROVIDER, M as GROUP_EDIT_TEXT_BYTES, Ma as OwnInviteError, Mi as ReactionWindow, Mn as findEndpoint, Mr as HOLD_DATED_BACK_MS, Mt as encodeDidDhtPacket, N as carryMentions, Na as createChatInvite, Ni as nextReactionNumber, Nn as isFederationId, Nr as HOLD_LIMITS, O as GroupSession, Oa as emptyLinkRecords, Oi as readForwarded, On as decodeBolt11, Or as vapidKeysMatch, P as parseCommunityEdit, Pa as decodeInviteCode, Pi as queueReaction, Pn as parseCashuRequestPayload, Pr as HoldKeys, Pt as signDidDhtPacket, Qi as claimedTime, Qn as mergeLobby, Qr as receivedIdentityStatus, Qt as sparkInvoiceDetails, Ra as DEFAULT_RELAYS, Ri as validReactionNumber, Rn as LIVENESS_PING_MS, Rr as CapsExchange, Rt as PaymentPreflightError, S as GroupWakeLimiter, Si as EDIT_BUFFER_MS, Sr as WAKE_CALL_INTERVAL_MS, T as parseGroupWakeFrame, Ti as dhtEditId, Tr as relayRequest, Ua as traceLink, Ui as BUTTON_ID, Un as pickMeshHubs, Ut as WALLET_NETWORKS, Va as normalizeRelayUrl, Vi as readReply, Vn as mayBeHub, Vr as dialDescriptors, Vt as TEST_USDT_FAUCET_AMOUNT, Wa as budgetRetryMs, Wn as COMMUNITY_TOPOLOGY, Wr as IDENTITY_REVOCATION_LABEL, Wt as assertTokenUnits, Xi as statusCardText, Xn as lobbyRecords, Xr as identityStatement, Xt as SPARK_PROVIDER, Ya as createRelayPayload, Yi as sameButtonText, Yn as lobbyKeys, Yr as identityRevocationValue, Yt as SPARK_NETWORKS, Zi as arrivalKey, Zn as mergeBeacon, Zr as newIdentityBinding, Zt as sparkAddressKind, _ as knockIdentity, _a as decodeControl, _i as parseVoiceMeta, _r as mayPin, a as sealGroupTyping, aa as rosterHas, ai as automaticTransport, ao as bytesEqual, ar as shouldBeHub, b as readKnocks, ba as RTC_CONFIG, bi as DHT_MESSAGE_TTL, br as pinNumberHolds, c as GROUP_READ_NOTE_COMMUNITY, ca as DirectPathWatch, ci as parseImageMeta, co as toBase64Url, cr as transferEnded, d as decodeCommunityLink, da as presenceSeenAt, do as utf8Encode, ea as receivedTimestamp, ei as lastSharedWithMe, eo as identityFromSeed, er as pickHubs, f as encodeCommunityLink, fa as GhostlyHttpError, fr as TypingSender, g as entryParams, ga as LIMITS, gi as fileMessageText, gr as PIN_LIMITS, h as encodeGroupEntryLink, ha as webLocalFetch, i as openGroupTyping, ia as MEMBER_KEY, ii as TRANSPORTS, io as sanitizeAvatar, ir as readLobby, ja as serviceIdFromName, ji as REACTION_LIMITS, jn as fedimintRequestPayload, jr as HOLD_CAPABILITY, jt as encodeDidDhtDeactivated, k as groupMessageId, ka as HTTP_SERVICE_PROTO, ki as COMMUNITY_REACTION_FRAME, kn as ENDPOINT, kt as didDhtDocument, l as communityHasFrame, lo as toZ32, lr as transferStalled, m as decodeGroupEntryLink, ma as parseLocalTarget, mr as typingActivity, n as GroupTypingBoard, na as GROUP_MEMBER_CAP, ni as emptyProofLedger, no as publicKeyFromZ32, nr as readBeacon, nt as isAtprotoDid, o as COMMUNITY_LIMITS, oa as verifyCommitSignature, oi as irohDescriptor, oo as fromBase64Url, or as ChatFiles, pa as formatLocalTarget, qa as PacketTooLargeError, qn as doorHubs, qt as validatePaymentTarget, r as groupTypingEpoch, ri as validExternalKey, rr as readBeaconHead, rt as normalizeAtprotoHandle, s as CommunitySession, sa as edgeParams, si as irohRelayUrl, so as randomBytes, sr as FILE_LIMITS, ta as shownTime, ti as PeerProofs, tn as isPubkyKey, to as identityFromSeedB64, tr as rankHubs, u as communityMessageId, ua as RELAY_POLL_INTERVALS, ui as safeBlobType, uo as utf8Decode, v as knockRecords, va as encodeControl, vr as nextPinNumber, w as groupWakes, wi as RateWindow, wr as newWakeToken, xi as peekDhtMailbox, xr as readPin, y as mergeKnocks, yi as parseVideoMeta, yr as pinIsNewer, za as RelayTransport, zi as wireReaction, zn as pairedMessageFrame, zr as DHT_TEXT_CAPABILITY, zt as SEPOLIA_TEST_USDT } from "./src-7sdQpCoi.js";
|
|
3
|
+
import { a as deleteDatabase, c as profileOpenFailure, d as wrap, i as databaseName, l as store, n as STORES, o as fileStore, r as clearProfileStores, s as openDb, t as CARD_INDEX, u as transact } from "./idb-DIN_Miwb.js";
|
|
4
|
+
import { $ as boundedIdentityFetch, A as TEST_MINT, B as registeredLightningProviders, C as networkLabel, D as WALLET_TYPES, F as paysItsOwnInvoices, G as SourceConfigError, H as reserveAdapterIds, I as BACKUP_MEDIA_TYPE, J as networkMode, K as SourceUnreachableError, L as MAX_BACKUP_BYTES, M as isTestMint, N as isWorthlessMint, O as BITCOIN_INVOICE_ON_TESTNET, P as mintNetwork, Q as setOwnDidSource, R as identityProviders, S as WrongNetworkError, T as cashuMint, U as NothingSpentError, V as registeredOnchainProviders, W as PROVIDER_PLATFORMS, X as assertPublicServer, Y as redact, Z as resolveAtprotoDid, _ as newDeviceKey, a as createFailure, at as replyRef, b as ModeChanged, c as paymentNetwork, ct as paymentWireId, d as networkReason, dt as removeStored, et as verifyIdentity, f as rpcHost, ft as storedSize, g as intentRepository, h as ProviderSources, i as assertConfirmedReal, it as receivedPairedReply, j as fakesLightning, k as DEFAULT_MINTS, l as paymentNetworksOf, lt as FileAppender, m as LightningCards, n as loadFedimintSdk, nt as groupReply, o as createTiming, ot as replyTo, p as CASHU_CARD, pt as streamStored, q as isNothingSpentError, r as WALLET_NAMES, rt as pairedWireReply, s as crossNetwork, st as paymentAlias, t as fedimintDatabase, tt as chosenResolver, u as walletInstances, ut as readStored, v as sealSeed, w as CASHU_MINT_SOURCE, x as ModeGate, y as unsealSeed, z as onAdaptersChanged } from "../ghostly.mjs";
|
|
2
5
|
import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
|
|
3
6
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
4
7
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
@@ -12,11 +15,112 @@ import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
|
|
|
12
15
|
import { Interface, Transaction, getAddress, keccak256 } from "ethers";
|
|
13
16
|
import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
|
|
14
17
|
import { tapLeafHash } from "@scure/btc-signer/payment.js";
|
|
18
|
+
import { Amount, MeltChangeError, OutputData, Wallet as Wallet$1, decodePaymentRequest, deserializeSwapPreview, getEncodedToken, getTokenMetadata, isMintOperationError, serializeSwapPreview } from "@cashu/cashu-ts";
|
|
15
19
|
import { Transaction as Transaction$2 } from "@scure/btc-signer";
|
|
16
20
|
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
17
21
|
import * as nip04 from "nostr-tools/nip04";
|
|
18
22
|
import * as nip44 from "nostr-tools/nip44";
|
|
19
|
-
|
|
23
|
+
/** Evidence older than this says nothing about the clock now. */
|
|
24
|
+
var CLOCK_EVIDENCE_MS = 18e5;
|
|
25
|
+
/** Sources kept of each kind: a profile has a few relays, and may have hundreds of contacts. */
|
|
26
|
+
var KEPT = 64;
|
|
27
|
+
/** -1: everything the sample allows is behind by more than `CLOCK_OFF_MS`; 1: ahead; 0: it does not say. */
|
|
28
|
+
var says = (sample) => sample.low > 6e4 ? 1 : sample.high < -6e4 ? -1 : 0;
|
|
29
|
+
var middle = (values) => {
|
|
30
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
31
|
+
return sorted[Math.floor((sorted.length - 1) / 2)];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The rule alone: how far this clock is from the others' (ms, positive when it is ahead) when the samples fresh at
|
|
35
|
+
* `now` say it is clearly off, else null.
|
|
36
|
+
*/
|
|
37
|
+
function clockOffset(servers, peers, now) {
|
|
38
|
+
const fresh = (samples) => [...samples.values()].filter((s) => now - s.at < CLOCK_EVIDENCE_MS);
|
|
39
|
+
const s = fresh(servers), p = fresh(peers);
|
|
40
|
+
const mid = (sample) => (sample.low + sample.high) / 2;
|
|
41
|
+
for (const way of [1, -1]) {
|
|
42
|
+
const serversOff = s.filter((x) => says(x) === way), peersOff = p.filter((x) => says(x) === way);
|
|
43
|
+
const serversNot = s.length - serversOff.length, peersNot = p.length - peersOff.length;
|
|
44
|
+
if (serversOff.length >= 2 && serversNot === 0) return Math.round(middle(serversOff.map(mid)));
|
|
45
|
+
if (serversNot > 0) continue;
|
|
46
|
+
if (serversOff.length === 1 && peersOff.length >= 1 && peersNot === 0) return Math.round(middle([...serversOff, ...peersOff].map(mid)));
|
|
47
|
+
if (peersOff.length >= 3 && peersOff.length >= 3 * peersNot) return Math.round(middle(peersOff.map(mid)));
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
var ClockWatch = class {
|
|
52
|
+
onChange;
|
|
53
|
+
now;
|
|
54
|
+
servers = /* @__PURE__ */ new Map();
|
|
55
|
+
peers = /* @__PURE__ */ new Map();
|
|
56
|
+
was = null;
|
|
57
|
+
timer = null;
|
|
58
|
+
/** `onChange`: what `offset` says changed (it appeared, went away, or moved by a minute). */
|
|
59
|
+
constructor(onChange = () => {}, now = Date.now) {
|
|
60
|
+
this.onChange = onChange;
|
|
61
|
+
this.now = now;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A server's time: `date` (ms, to the second) from an answer to a request that went out at `sent` and came back at
|
|
65
|
+
* `received`, both by this clock.
|
|
66
|
+
*/
|
|
67
|
+
server(source, date, sent, received) {
|
|
68
|
+
if (!Number.isFinite(date) || date <= 0 || received < sent) return;
|
|
69
|
+
this.keep(this.servers, source, {
|
|
70
|
+
low: sent - date - 999,
|
|
71
|
+
high: received - date,
|
|
72
|
+
at: this.now()
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A contact's packet, dated `packetAt` by its clock, that came between this device's reads of its record at
|
|
77
|
+
* `readBefore` and `readAt` (this clock). Says nothing when the two reads were far apart.
|
|
78
|
+
*/
|
|
79
|
+
peer(peer, packetAt, readBefore, readAt) {
|
|
80
|
+
if (!(readBefore > 0) || readAt < readBefore || readAt - readBefore > 15e3 || !Number.isFinite(packetAt)) return;
|
|
81
|
+
this.keep(this.peers, peer, {
|
|
82
|
+
low: readBefore - packetAt,
|
|
83
|
+
high: readAt - packetAt,
|
|
84
|
+
at: this.now()
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
keep(samples, source, sample) {
|
|
88
|
+
samples.delete(source);
|
|
89
|
+
samples.set(source, sample);
|
|
90
|
+
if (samples.size > KEPT) samples.delete(samples.keys().next().value);
|
|
91
|
+
this.settle();
|
|
92
|
+
}
|
|
93
|
+
/** This clock minus the others', in ms (positive: ahead), when it is clearly off; else null. */
|
|
94
|
+
get offset() {
|
|
95
|
+
return clockOffset(this.servers, this.peers, this.now());
|
|
96
|
+
}
|
|
97
|
+
/** The clock was set, or the evidence is of another time: nothing is known until the next answers. */
|
|
98
|
+
reset() {
|
|
99
|
+
this.servers.clear();
|
|
100
|
+
this.peers.clear();
|
|
101
|
+
this.settle();
|
|
102
|
+
}
|
|
103
|
+
/** Stops the timer that lets the evidence age out. */
|
|
104
|
+
close() {
|
|
105
|
+
if (this.timer) clearTimeout(this.timer);
|
|
106
|
+
this.timer = null;
|
|
107
|
+
}
|
|
108
|
+
settle() {
|
|
109
|
+
this.close();
|
|
110
|
+
for (const samples of [this.servers, this.peers]) for (const [source, sample] of samples) if (this.now() - sample.at >= 18e5) samples.delete(source);
|
|
111
|
+
const offset = this.offset;
|
|
112
|
+
if (offset !== null) {
|
|
113
|
+
const oldest = Math.min(...[...this.servers.values(), ...this.peers.values()].map((s) => s.at));
|
|
114
|
+
this.timer = setTimeout(() => this.settle(), Math.max(1e3, oldest + CLOCK_EVIDENCE_MS - this.now()));
|
|
115
|
+
this.timer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
const reads = (value) => value === null ? null : Math.round(value / 6e4);
|
|
118
|
+
if (reads(offset) === reads(this.was)) return;
|
|
119
|
+
this.was = offset;
|
|
120
|
+
this.onChange(offset);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
//#endregion
|
|
20
124
|
//#region ../browser/src/engine/paymentAdapters/walletNetworks.ts
|
|
21
125
|
/** Where the wallet of one network is stored. The same key an older app parks it under, so it finds it too. */
|
|
22
126
|
var walletKey = (rail, network) => `${rail}-mode-${network}`;
|
|
@@ -3960,26 +4064,59 @@ var CashuAdapter = class {
|
|
|
3960
4064
|
token = await this.wallet.executeReviewedCashu(review, prepared);
|
|
3961
4065
|
} catch (error) {
|
|
3962
4066
|
prepared.attemptEndedAt = Date.now();
|
|
4067
|
+
if (persist && isMintOperationError(error) && await this.wallet.reviewedCashuSpentElsewhere(prepared).catch(() => false)) return this.spentElsewhere(prepared, persist);
|
|
3963
4068
|
throw error;
|
|
3964
4069
|
}
|
|
3965
4070
|
await persist?.();
|
|
3966
4071
|
await this.publish(review, token);
|
|
3967
|
-
return
|
|
4072
|
+
return await this.outcome(review, prepared) ?? { settled: false };
|
|
4073
|
+
}
|
|
4074
|
+
/**
|
|
4075
|
+
* What the mint and this wallet know of a payment whose token went out: paid once the mint reads its ecash spent,
|
|
4076
|
+
* unless this wallet is the one that spent it, taking the payment back. Asked in that order: ecash taken back while
|
|
4077
|
+
* the mint was being asked reads spent too. Undefined while the contact has not taken it.
|
|
4078
|
+
*/
|
|
4079
|
+
async outcome(review, prepared) {
|
|
4080
|
+
const spent = await this.wallet.reviewedCashuSpent(prepared);
|
|
4081
|
+
if (await this.wallet.reviewedCashuTakenBack(review.id)) return {
|
|
4082
|
+
settled: false,
|
|
4083
|
+
failed: true,
|
|
4084
|
+
error: engineText("paymentTakenBack")
|
|
4085
|
+
};
|
|
4086
|
+
return spent ? { settled: true } : void 0;
|
|
3968
4087
|
}
|
|
3969
4088
|
/** `persist` saves the intent as it is: required, since the abandon path must be durable before it releases anything. */
|
|
3970
4089
|
async reconcile(review, prepared, persist) {
|
|
3971
4090
|
if (prepared.abandoned) return this.abandon(prepared, persist);
|
|
3972
|
-
if (
|
|
4091
|
+
if (prepared.spentElsewhere) return this.spentElsewhere(prepared, persist);
|
|
4092
|
+
const known = await this.outcome(review, prepared);
|
|
4093
|
+
if (known) return known;
|
|
3973
4094
|
const token = prepared.token ?? await this.wallet.recoverReviewedCashu(review, prepared);
|
|
3974
4095
|
if (token) await this.publish(review, token);
|
|
3975
|
-
|
|
4096
|
+
const after = await this.outcome(review, prepared);
|
|
4097
|
+
if (after) return after;
|
|
3976
4098
|
if (!token && Date.now() >= swapSettledAt(review, prepared) && await this.wallet.reviewedCashuNeverSwapped(prepared)) return this.abandon(prepared, persist);
|
|
4099
|
+
if (!token && await this.wallet.reviewedCashuSpentElsewhere(prepared)) return this.spentElsewhere(prepared, persist);
|
|
3977
4100
|
return { settled: false };
|
|
3978
4101
|
}
|
|
3979
4102
|
/**
|
|
3980
4103
|
* The mint proved this swap never happened. Marked for good first (a reload repeats only this), then its reserved
|
|
3981
4104
|
* inputs are released. Nothing sends the saved swap again: approve claims pending reviews only, and reconcile asks.
|
|
3982
4105
|
*/
|
|
4106
|
+
/**
|
|
4107
|
+
* The ecash this payment was to spend is spent, and not by this payment. Marked for good first, then the wallet lets
|
|
4108
|
+
* go of what the mint reads spent, frees the rest, and asks the mint about its other ecash there.
|
|
4109
|
+
*/
|
|
4110
|
+
async spentElsewhere(prepared, persist) {
|
|
4111
|
+
prepared.spentElsewhere = true;
|
|
4112
|
+
await persist();
|
|
4113
|
+
await this.wallet.dropReviewedCashu(prepared);
|
|
4114
|
+
return {
|
|
4115
|
+
settled: false,
|
|
4116
|
+
failed: true,
|
|
4117
|
+
error: engineText("reviewedEcashSpent")
|
|
4118
|
+
};
|
|
4119
|
+
}
|
|
3983
4120
|
async abandon(prepared, persist) {
|
|
3984
4121
|
prepared.abandoned = true;
|
|
3985
4122
|
await persist();
|
|
@@ -4146,7 +4283,15 @@ var BitcoinService = class {
|
|
|
4146
4283
|
failed: true,
|
|
4147
4284
|
error: "Another transaction spent these coins: this payment can never confirm"
|
|
4148
4285
|
};
|
|
4149
|
-
if (status.state === "missing")
|
|
4286
|
+
if (status.state === "missing") {
|
|
4287
|
+
if (!review.submittedAt) return {
|
|
4288
|
+
txid: prepared.txid,
|
|
4289
|
+
settled: false,
|
|
4290
|
+
failed: true,
|
|
4291
|
+
error: "This payment was never approved: nothing was sent"
|
|
4292
|
+
};
|
|
4293
|
+
await provider.broadcast(prepared).catch(() => {});
|
|
4294
|
+
}
|
|
4150
4295
|
return {
|
|
4151
4296
|
txid: prepared.txid,
|
|
4152
4297
|
settled: false,
|
|
@@ -11531,6 +11676,7 @@ var FileDesk = class {
|
|
|
11531
11676
|
if (chat) return chat;
|
|
11532
11677
|
const made = {
|
|
11533
11678
|
local: /* @__PURE__ */ new Map(),
|
|
11679
|
+
places: /* @__PURE__ */ new Map(),
|
|
11534
11680
|
saving: Promise.resolve(),
|
|
11535
11681
|
lastSaved: /* @__PURE__ */ new Map(),
|
|
11536
11682
|
files: void 0
|
|
@@ -11570,7 +11716,13 @@ var FileDesk = class {
|
|
|
11570
11716
|
this.deps.changed();
|
|
11571
11717
|
}
|
|
11572
11718
|
handle(linkId, frame) {
|
|
11573
|
-
|
|
11719
|
+
const chat = this.chat(linkId);
|
|
11720
|
+
const id = frame.t === "pf-offer" && typeof frame.id === "string" ? frame.id : void 0;
|
|
11721
|
+
if (id === void 0 || !this.deps.place || chat.local.has(id) || chat.places.has(id)) return chat.files.handle(frame);
|
|
11722
|
+
chat.places.set(id, this.deps.place(linkId));
|
|
11723
|
+
return chat.files.handle(frame).finally(() => {
|
|
11724
|
+
if (!chat.local.has(id)) chat.places.delete(id);
|
|
11725
|
+
});
|
|
11574
11726
|
}
|
|
11575
11727
|
/** Whether files/3 is live in this chat, and what the contact said it can take. */
|
|
11576
11728
|
status(linkId) {
|
|
@@ -11798,6 +11950,8 @@ var FileDesk = class {
|
|
|
11798
11950
|
...file.video && { video: file.video },
|
|
11799
11951
|
...file.image && { image: file.image }
|
|
11800
11952
|
};
|
|
11953
|
+
const place = chat.places.get(record.id), sentAt = claimedTime(file.timestamp);
|
|
11954
|
+
chat.places.delete(record.id);
|
|
11801
11955
|
chat.saving = chat.saving.then(async () => {
|
|
11802
11956
|
await fileStore.put({
|
|
11803
11957
|
id,
|
|
@@ -11822,9 +11976,12 @@ var FileDesk = class {
|
|
|
11822
11976
|
id: `peer_${record.id}`,
|
|
11823
11977
|
text: fileMessageText(message),
|
|
11824
11978
|
sender: "peer",
|
|
11825
|
-
timestamp: file.timestamp,
|
|
11826
11979
|
via: "datalink",
|
|
11827
11980
|
file: message,
|
|
11981
|
+
...place !== void 0 && sentAt !== void 0 ? {
|
|
11982
|
+
timestamp: place,
|
|
11983
|
+
sentAt
|
|
11984
|
+
} : { timestamp: file.timestamp },
|
|
11828
11985
|
...file.reply && { replyTo: receivedPairedReply(file.reply) },
|
|
11829
11986
|
...file.forwarded && { forwarded: file.forwarded },
|
|
11830
11987
|
details: { wire: fileWire("files/3", file.size) }
|
|
@@ -12287,9 +12444,32 @@ var RESEND_POLICY = {
|
|
|
12287
12444
|
/** Queued this long with the contact away, it is handed to store-and-forward when the chat allows it. */
|
|
12288
12445
|
holdAfterMs: 6e4
|
|
12289
12446
|
};
|
|
12447
|
+
/** How long one turn of an outbox may hold the next one back: a send that never returns must not stop the chat. */
|
|
12448
|
+
var TURN_MS = 3e4;
|
|
12449
|
+
/**
|
|
12450
|
+
* A text of mine, not handed to store-and-forward, and still to go: queued, or waiting for the chat to carry it. (A
|
|
12451
|
+
* file or a payment request that waits for the live session has no wire id, and its own sender.)
|
|
12452
|
+
*/
|
|
12453
|
+
var toGo = (m) => m.sender === "me" && m.via !== "hold" && !!m.wireId && (m.delivery === "queued" || m.delivery === "waiting");
|
|
12454
|
+
/** A file of mine that waits for the live link (its row has no wire id: the transfer has its own). */
|
|
12455
|
+
var waitingFile = (m) => m.sender === "me" && m.via !== "hold" && !m.wireId && !!m.file && m.delivery === "waiting";
|
|
12456
|
+
var oldestFirst = (a, b) => a.timestamp - b.timestamp;
|
|
12290
12457
|
/** Durable message IDs survive retries. A lost receipt means unknown delivery, never a claim that the peer did
|
|
12291
12458
|
* not receive the message: the receiver acknowledges a duplicate id without showing it twice, so an unconfirmed
|
|
12292
|
-
* message is sent again by itself while the chat can carry it, within `RESEND_POLICY`, then waits for Retry.
|
|
12459
|
+
* message is sent again by itself while the chat can carry it, within `RESEND_POLICY`, then waits for Retry.
|
|
12460
|
+
*
|
|
12461
|
+
* What a chat's outbox hands its link goes oldest first. The contact's app places each message where it arrives
|
|
12462
|
+
* (WISP 400, requirement 10), so the order they are sent in is the order the contact reads them in, for good. A new
|
|
12463
|
+
* message, a link that came back, a receipt that freed the DHT path and each queued message's own timer all send, and
|
|
12464
|
+
* each of them took its turn while another was still between two steps: a message written later went out before one
|
|
12465
|
+
* written earlier, and the contact kept them that way. They now take turns (`turn`), and each turn sends what is
|
|
12466
|
+
* older first. Only a message the chat cannot carry (a long text while on the DHT) is passed by a later one.
|
|
12467
|
+
*
|
|
12468
|
+
* A link that closes takes its turn too (`disconnected`), and so does a receipt that never came. Both make a sent
|
|
12469
|
+
* message queued again, and both did it beside a look in progress: a link that dropped with a hundred texts
|
|
12470
|
+
* unconfirmed and came back within a second was looked at while half of them were between sent and queued, the look
|
|
12471
|
+
* passed those by and sent the later ones first. A look in progress when the link closes stops there: what it sent
|
|
12472
|
+
* on the old link may be lost, and the ones after them must not go on the new link before them. */
|
|
12293
12473
|
var Outbox = class {
|
|
12294
12474
|
store;
|
|
12295
12475
|
send;
|
|
@@ -12301,8 +12481,18 @@ var Outbox = class {
|
|
|
12301
12481
|
waiting = /* @__PURE__ */ new Map();
|
|
12302
12482
|
attempts = /* @__PURE__ */ new Map();
|
|
12303
12483
|
busy = /* @__PURE__ */ new Set();
|
|
12304
|
-
/**
|
|
12484
|
+
/** Still in flight when the look a reopened chat asked for began (`flush({ reopened })`): sent again once that send returns. */
|
|
12305
12485
|
reopenedWhileBusy = /* @__PURE__ */ new Set();
|
|
12486
|
+
/** The turns taken so far, one after the other (`turn`). */
|
|
12487
|
+
line = Promise.resolve();
|
|
12488
|
+
/** How many times the chat reopened, and the count each message last went out under: one sent since is on the new link. */
|
|
12489
|
+
opened = 0;
|
|
12490
|
+
sentUnder = /* @__PURE__ */ new Map();
|
|
12491
|
+
/** How many times the link closed with something unconfirmed on it, and the count when the turn in progress began. */
|
|
12492
|
+
closed = 0;
|
|
12493
|
+
began = 0;
|
|
12494
|
+
/** The link closed and what was unconfirmed on it is not queued again yet: the next turn does it first. */
|
|
12495
|
+
dropped = false;
|
|
12306
12496
|
stopped = false;
|
|
12307
12497
|
resender;
|
|
12308
12498
|
policy;
|
|
@@ -12339,13 +12529,137 @@ var Outbox = class {
|
|
|
12339
12529
|
else await this.queue(message, "Not confirmed before the app closed. It is sent again, under the same ID, once your contact is reachable.");
|
|
12340
12530
|
}
|
|
12341
12531
|
}
|
|
12342
|
-
/**
|
|
12343
|
-
|
|
12532
|
+
/**
|
|
12533
|
+
* One at a time, in the order asked: `run` starts when the turns before it are over. A turn that takes longer than
|
|
12534
|
+
* `TURN_MS` no longer holds the next one back.
|
|
12535
|
+
*/
|
|
12536
|
+
turn(run) {
|
|
12537
|
+
const result = this.line.then(async () => {
|
|
12538
|
+
if (this.dropped) {
|
|
12539
|
+
this.dropped = false;
|
|
12540
|
+
await this.requeueUnconfirmed();
|
|
12541
|
+
}
|
|
12542
|
+
this.began = this.closed;
|
|
12543
|
+
return run();
|
|
12544
|
+
});
|
|
12545
|
+
this.line = new Promise((next) => {
|
|
12546
|
+
const timer = setTimeout(next, TURN_MS);
|
|
12547
|
+
result.then(() => {}, () => {}).then(() => {
|
|
12548
|
+
clearTimeout(timer);
|
|
12549
|
+
next();
|
|
12550
|
+
});
|
|
12551
|
+
});
|
|
12552
|
+
return result;
|
|
12553
|
+
}
|
|
12554
|
+
/** The link has not closed since this turn began: what the turn sends goes on the link the turn looked at. */
|
|
12555
|
+
get sameLink() {
|
|
12556
|
+
return !this.stopped && this.closed === this.began;
|
|
12557
|
+
}
|
|
12558
|
+
/** A new message a turn could not send (the link closed under it, or the ones before it still wait): it waits too. */
|
|
12559
|
+
async waitsItsTurn(message) {
|
|
12560
|
+
await this.store.update(message.id, "waiting", "Waits for the messages before it to go.", {
|
|
12561
|
+
via: "datalink",
|
|
12562
|
+
resendUntil: message.resendUntil ?? this.now() + this.policy.windowMs
|
|
12563
|
+
});
|
|
12564
|
+
this.schedule(message.id);
|
|
12565
|
+
}
|
|
12566
|
+
/**
|
|
12567
|
+
* Sends one message. A new one goes after the ones written before it that still wait to go: those the chat can
|
|
12568
|
+
* carry go first, and if it then cannot carry this one (the DHT path took one of them), it waits its turn too.
|
|
12569
|
+
* `manual`: the Retry button — it goes now, with a new window and a fresh count of attempts.
|
|
12570
|
+
*/
|
|
12571
|
+
transmit(id, { manual = false } = {}) {
|
|
12572
|
+
return this.turn(async () => {
|
|
12573
|
+
if (manual || this.stopped || this.busy.has(id) || this.receipts.has(id)) return this.transmitNow(id, { manual });
|
|
12574
|
+
const messages = await this.store.read(), message = messages.find((m) => m.id === id);
|
|
12575
|
+
const before = message?.delivery === "sending" ? this.before(messages, message) : [];
|
|
12576
|
+
if (!before.length) return this.transmitNow(id, {}, message);
|
|
12577
|
+
const passed = await this.inOrder(before);
|
|
12578
|
+
if (this.stopped) return;
|
|
12579
|
+
if (this.sameLink && this.resender.ready({
|
|
12580
|
+
...message,
|
|
12581
|
+
via: "datalink"
|
|
12582
|
+
})) {
|
|
12583
|
+
await this.catchUp(passed);
|
|
12584
|
+
if (this.sameLink) return this.transmitNow(id);
|
|
12585
|
+
}
|
|
12586
|
+
if (this.stopped) return;
|
|
12587
|
+
await this.waitsItsTurn(message);
|
|
12588
|
+
});
|
|
12589
|
+
}
|
|
12590
|
+
/** The messages written before a new one that have not gone yet, oldest first. */
|
|
12591
|
+
before(messages, message) {
|
|
12592
|
+
const stored = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sending" && !!m.wireId && m.timestamp < message.timestamp && !this.receipts.has(m.id) && !this.busy.has(m.id);
|
|
12593
|
+
return [...this.due(messages.filter((m) => m.id !== message.id && m.timestamp <= message.timestamp)), ...messages.filter(stored)].sort(oldestFirst);
|
|
12594
|
+
}
|
|
12595
|
+
/**
|
|
12596
|
+
* What a look at the chat takes up, oldest first: what is queued or waiting, and what was sent and still awaits its
|
|
12597
|
+
* receipt when the path it took is not the one the chat has now (it went on the DHT and the chat is live: it goes on
|
|
12598
|
+
* the link, before what was written after it) or when the chat reopened since (`opened`).
|
|
12599
|
+
*/
|
|
12600
|
+
due(messages, opened) {
|
|
12601
|
+
const unconfirmed = (m) => m.sender === "me" && m.via !== "hold" && m.delivery === "sent" && this.receipts.has(m.id);
|
|
12602
|
+
return messages.filter((m) => !this.busy.has(m.id) && (toGo(m) || this.file(m) || unconfirmed(m) && (opened !== void 0 && (this.sentUnder.get(m.id) ?? 0) < opened || !!this.resender.via && this.resender.via(m) !== m.via && this.resender.ready(m)))).sort(oldestFirst);
|
|
12603
|
+
}
|
|
12604
|
+
/**
|
|
12605
|
+
* Looks at each message, oldest first: it goes if the chat can carry it. The ones the chat could not carry are
|
|
12606
|
+
* handed back, and asked once more at the moment a later one is found able to go (`catchUp`): a link says it is open
|
|
12607
|
+
* a moment before it carries text, and a look that began in that moment sent the later messages and left the first
|
|
12608
|
+
* ones behind.
|
|
12609
|
+
*/
|
|
12610
|
+
/** Whether this chat's waiting files go through this outbox (`Resender.sendFile`). */
|
|
12611
|
+
get sendsFiles() {
|
|
12612
|
+
return !!this.resender.sendFile;
|
|
12613
|
+
}
|
|
12614
|
+
file(message) {
|
|
12615
|
+
return this.sendsFiles && waitingFile(message);
|
|
12616
|
+
}
|
|
12617
|
+
/** A waiting file is said on the link, after the older messages passed by that can go now. False: the link cannot carry it. */
|
|
12618
|
+
async fileGoes(message, passed) {
|
|
12619
|
+
if (!this.resender.ready(message)) {
|
|
12620
|
+
if (!this.waiting.get(message.id)?.timer) this.schedule(message.id);
|
|
12621
|
+
return false;
|
|
12622
|
+
}
|
|
12623
|
+
if (passed) await this.catchUp(passed);
|
|
12624
|
+
if (!this.sameLink) return true;
|
|
12625
|
+
await this.resender.sendFile(message).catch(() => {});
|
|
12626
|
+
this.forget(message.id);
|
|
12627
|
+
return true;
|
|
12628
|
+
}
|
|
12629
|
+
async inOrder(messages) {
|
|
12630
|
+
const passed = [];
|
|
12631
|
+
for (const message of messages) {
|
|
12632
|
+
if (!this.sameLink) break;
|
|
12633
|
+
if (message.delivery === "sending") {
|
|
12634
|
+
await this.catchUp(passed);
|
|
12635
|
+
if (this.sameLink) await this.transmitNow(message.id);
|
|
12636
|
+
else if (!this.stopped) await this.waitsItsTurn(message);
|
|
12637
|
+
continue;
|
|
12638
|
+
}
|
|
12639
|
+
if (!await (this.file(message) ? this.fileGoes(message, passed) : this.again(message, passed))) passed.push(message);
|
|
12640
|
+
}
|
|
12641
|
+
return passed;
|
|
12642
|
+
}
|
|
12643
|
+
/** The messages passed by that the chat can carry by now go, oldest first; the others stay where they are. */
|
|
12644
|
+
async catchUp(passed) {
|
|
12645
|
+
for (let i = 0; i < passed.length && this.sameLink;) {
|
|
12646
|
+
if (!this.resender.ready(passed[i])) {
|
|
12647
|
+
i++;
|
|
12648
|
+
continue;
|
|
12649
|
+
}
|
|
12650
|
+
const [older] = passed.splice(i, 1);
|
|
12651
|
+
if (this.file(older)) await this.fileGoes(older);
|
|
12652
|
+
else await this.attempt(older.id);
|
|
12653
|
+
i = 0;
|
|
12654
|
+
}
|
|
12655
|
+
}
|
|
12656
|
+
/** `read`: the message's row, when it was read just now with nothing awaited since. */
|
|
12657
|
+
async transmitNow(id, { manual = false } = {}, read) {
|
|
12344
12658
|
if (this.stopped || this.busy.has(id) || this.receipts.has(id)) return;
|
|
12345
12659
|
this.busy.add(id);
|
|
12346
12660
|
let again;
|
|
12347
12661
|
try {
|
|
12348
|
-
let message = (await this.store.read()).find((m) => m.id === id);
|
|
12662
|
+
let message = read ?? (await this.store.read()).find((m) => m.id === id);
|
|
12349
12663
|
if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
|
|
12350
12664
|
const wait = this.waiting.get(id);
|
|
12351
12665
|
if (wait?.timer) {
|
|
@@ -12372,6 +12686,7 @@ var Outbox = class {
|
|
|
12372
12686
|
return;
|
|
12373
12687
|
}
|
|
12374
12688
|
this.track(id, typeof this.timeoutMs === "number" ? this.timeoutMs : this.timeoutMs(message));
|
|
12689
|
+
this.sentUnder.set(id, this.opened);
|
|
12375
12690
|
const error = await this.send(message);
|
|
12376
12691
|
if (error) {
|
|
12377
12692
|
this.clear(id);
|
|
@@ -12412,25 +12727,53 @@ var Outbox = class {
|
|
|
12412
12727
|
* oldest first. `reopened`: a new link, so what still awaits a receipt from the old one, or from the DHT, goes on it too
|
|
12413
12728
|
* (a receipt on the link also ends the DHT's own retries).
|
|
12414
12729
|
*/
|
|
12415
|
-
|
|
12416
|
-
if (this.stopped || !reopened && !this.waiting.size) return;
|
|
12417
|
-
|
|
12418
|
-
|
|
12419
|
-
|
|
12420
|
-
|
|
12421
|
-
|
|
12422
|
-
|
|
12423
|
-
|
|
12424
|
-
|
|
12425
|
-
|
|
12730
|
+
flush({ reopened = false } = {}) {
|
|
12731
|
+
if (this.stopped || !reopened && !this.waiting.size) return Promise.resolve();
|
|
12732
|
+
const opened = reopened ? ++this.opened : void 0;
|
|
12733
|
+
return this.turn(async () => {
|
|
12734
|
+
if (this.stopped) return;
|
|
12735
|
+
if (reopened) for (const id of this.busy) this.reopenedWhileBusy.add(id);
|
|
12736
|
+
await this.inOrder(this.due(await this.store.read(), opened));
|
|
12737
|
+
});
|
|
12738
|
+
}
|
|
12739
|
+
/** A queued message's check came due: the ones written before it that have not gone are looked at first. */
|
|
12740
|
+
async check(id) {
|
|
12741
|
+
if (this.stopped) return;
|
|
12742
|
+
const messages = await this.store.read(), message = messages.find((m) => m.id === id);
|
|
12743
|
+
if (!message || !(toGo(message) || this.file(message))) {
|
|
12744
|
+
await this.attempt(id);
|
|
12426
12745
|
return;
|
|
12427
12746
|
}
|
|
12428
|
-
|
|
12747
|
+
await this.inOrder(this.due(messages.filter((m) => m.timestamp <= message.timestamp)));
|
|
12748
|
+
}
|
|
12749
|
+
/**
|
|
12750
|
+
* A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry
|
|
12751
|
+
* it. False when the chat could not carry it.
|
|
12752
|
+
*/
|
|
12753
|
+
async again(message, passed) {
|
|
12754
|
+
if (message.sender !== "me" || message.via === "hold") return true;
|
|
12755
|
+
if (message.delivery === "queued" || message.delivery === "waiting") return this.attempt(message.id, passed);
|
|
12756
|
+
if (message.delivery !== "sent" || !this.receipts.has(message.id)) return true;
|
|
12429
12757
|
this.clear(message.id);
|
|
12430
|
-
if (this.resender.ready(message))
|
|
12431
|
-
|
|
12758
|
+
if (this.resender.ready(message)) {
|
|
12759
|
+
if (passed) await this.catchUp(passed);
|
|
12760
|
+
await this.transmitNow(message.id);
|
|
12761
|
+
return true;
|
|
12762
|
+
}
|
|
12763
|
+
await this.requeue(message.id, "Connection closed before receipt. Delivery is unconfirmed.");
|
|
12764
|
+
return false;
|
|
12765
|
+
}
|
|
12766
|
+
/**
|
|
12767
|
+
* The link closed: what awaits a receipt from it is queued again, in a turn of its own, so a look at the chat never
|
|
12768
|
+
* finds a message between sent and queued. A look in progress stops (`sameLink`).
|
|
12769
|
+
*/
|
|
12770
|
+
disconnected() {
|
|
12771
|
+
if (!this.receipts.size) return Promise.resolve();
|
|
12772
|
+
this.closed++;
|
|
12773
|
+
this.dropped = true;
|
|
12774
|
+
return this.turn(async () => {});
|
|
12432
12775
|
}
|
|
12433
|
-
async
|
|
12776
|
+
async requeueUnconfirmed() {
|
|
12434
12777
|
const messages = await this.store.read();
|
|
12435
12778
|
for (const id of [...this.receipts.keys()]) {
|
|
12436
12779
|
if (messages.find((m) => m.id === id)?.via === "pkarr") {
|
|
@@ -12446,33 +12789,40 @@ var Outbox = class {
|
|
|
12446
12789
|
this.reopenedWhileBusy.clear();
|
|
12447
12790
|
for (const wait of this.waiting.values()) if (wait.timer) clearTimeout(wait.timer);
|
|
12448
12791
|
this.waiting.clear();
|
|
12449
|
-
|
|
12792
|
+
this.dropped = false;
|
|
12793
|
+
await this.requeueUnconfirmed();
|
|
12450
12794
|
}
|
|
12451
|
-
/**
|
|
12452
|
-
|
|
12453
|
-
|
|
12795
|
+
/**
|
|
12796
|
+
* Looks at one queued message: sends it if the chat can carry it, hands it to store-and-forward, or waits more.
|
|
12797
|
+
* False when the chat could not carry it and it still waits. `passed`: older messages the chat could not carry a
|
|
12798
|
+
* moment ago, which go first if it can now.
|
|
12799
|
+
*/
|
|
12800
|
+
async attempt(id, passed) {
|
|
12801
|
+
if (this.stopped || this.busy.has(id)) return true;
|
|
12454
12802
|
const message = (await this.store.read()).find((m) => m.id === id);
|
|
12455
12803
|
if (!message || message.delivery !== "queued" && message.delivery !== "waiting" || message.via === "hold") {
|
|
12456
12804
|
this.forget(id);
|
|
12457
|
-
return;
|
|
12805
|
+
return true;
|
|
12458
12806
|
}
|
|
12459
12807
|
if ((message.resendUntil ?? Infinity) <= this.now()) {
|
|
12460
12808
|
if (message.delivery === "waiting") {
|
|
12461
12809
|
this.forget(id);
|
|
12462
12810
|
await this.store.update(id, "failed", `Your contact was not reachable within ${days(this.policy.windowMs)}. It was not sent; Retry sends it.`);
|
|
12463
12811
|
} else await this.fail(id, `Not confirmed within ${days(this.policy.windowMs)}.`);
|
|
12464
|
-
return;
|
|
12812
|
+
return true;
|
|
12465
12813
|
}
|
|
12466
12814
|
if (this.resender.ready(message)) {
|
|
12467
|
-
await this.
|
|
12468
|
-
|
|
12815
|
+
if (passed) await this.catchUp(passed);
|
|
12816
|
+
await this.transmitNow(id);
|
|
12817
|
+
return true;
|
|
12469
12818
|
}
|
|
12470
12819
|
const wait = this.waiting.get(id);
|
|
12471
12820
|
if (this.resender.divert && wait && this.now() - wait.since >= this.policy.holdAfterMs && await this.resender.divert(message)) {
|
|
12472
12821
|
this.forget(id);
|
|
12473
|
-
return;
|
|
12822
|
+
return true;
|
|
12474
12823
|
}
|
|
12475
12824
|
if (!wait?.timer) this.schedule(id);
|
|
12825
|
+
return false;
|
|
12476
12826
|
}
|
|
12477
12827
|
/** After a failed attempt: queued again, unless its window or its attempts are spent. */
|
|
12478
12828
|
async requeue(id, reason) {
|
|
@@ -12507,7 +12857,7 @@ var Outbox = class {
|
|
|
12507
12857
|
wait.timer = setTimeout(() => {
|
|
12508
12858
|
wait.timer = void 0;
|
|
12509
12859
|
wait.step++;
|
|
12510
|
-
this.
|
|
12860
|
+
this.turn(() => this.check(id)).catch(() => {});
|
|
12511
12861
|
}, delay);
|
|
12512
12862
|
this.waiting.set(id, wait);
|
|
12513
12863
|
}
|
|
@@ -12516,17 +12866,20 @@ var Outbox = class {
|
|
|
12516
12866
|
if (wait?.timer) clearTimeout(wait.timer);
|
|
12517
12867
|
this.waiting.delete(id);
|
|
12518
12868
|
this.attempts.delete(id);
|
|
12869
|
+
this.sentUnder.delete(id);
|
|
12519
12870
|
}
|
|
12520
12871
|
track(id, duration) {
|
|
12521
12872
|
this.clear(id);
|
|
12522
|
-
|
|
12523
|
-
this.
|
|
12524
|
-
|
|
12873
|
+
const timer = setTimeout(() => {
|
|
12874
|
+
this.turn(async () => {
|
|
12875
|
+
if (this.receipts.get(id) !== timer) return;
|
|
12876
|
+
this.receipts.delete(id);
|
|
12525
12877
|
const message = (await this.store.read()).find((m) => m.id === id);
|
|
12526
12878
|
if (message?.via === "pkarr") await this.expired(message);
|
|
12527
12879
|
else await this.requeue(id, "No receipt received yet.");
|
|
12528
|
-
})
|
|
12529
|
-
}, duration)
|
|
12880
|
+
}).catch(() => {});
|
|
12881
|
+
}, duration);
|
|
12882
|
+
this.receipts.set(id, timer);
|
|
12530
12883
|
}
|
|
12531
12884
|
clear(id) {
|
|
12532
12885
|
const timer = this.receipts.get(id);
|
|
@@ -12764,7 +13117,8 @@ var ITEM = {
|
|
|
12764
13117
|
invoice: (a) => `An invoice for ${a}, not paid yet`,
|
|
12765
13118
|
paid: (a) => `${a} paid to an invoice, not claimed from the mint yet`,
|
|
12766
13119
|
unclaimed: (a) => `${a} the mint says it issued for an invoice, never received here`,
|
|
12767
|
-
sent: (a) => `${a} in ecash you sent, not taken yet
|
|
13120
|
+
sent: (a) => `${a} in ecash you sent, not taken yet`,
|
|
13121
|
+
swap: (a) => `${a} in an exchange with the mint, not finished yet`
|
|
12768
13122
|
};
|
|
12769
13123
|
var ENGLISH_REMOVAL = {
|
|
12770
13124
|
sats: (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`,
|
|
@@ -12789,8 +13143,13 @@ function items(type, network, view, words, card) {
|
|
|
12789
13143
|
...a.paymentId ? { paymentId: a.paymentId } : {}
|
|
12790
13144
|
});
|
|
12791
13145
|
const returns = (a) => a.kind === "sent" && type === "cashu";
|
|
13146
|
+
const swaps = type === "cashu" && view?.openSwaps ? [{
|
|
13147
|
+
kind: "swap",
|
|
13148
|
+
text: words.item("swap", amount(view.swapsAmount ?? 0)),
|
|
13149
|
+
amount: amount(view.swapsAmount ?? 0)
|
|
13150
|
+
}] : [];
|
|
12792
13151
|
return {
|
|
12793
|
-
awaiting: mine.filter((a) => !returns(a)).map(item),
|
|
13152
|
+
awaiting: [...mine.filter((a) => !returns(a)).map(item), ...swaps],
|
|
12794
13153
|
returnable: mine.filter(returns).map(item)
|
|
12795
13154
|
};
|
|
12796
13155
|
}
|
|
@@ -12812,7 +13171,7 @@ function walletRemoval(type, network, view, intents = [], card, words = ENGLISH_
|
|
|
12812
13171
|
case "cashu": return {
|
|
12813
13172
|
...base,
|
|
12814
13173
|
custody: "device",
|
|
12815
|
-
held: known(view?.balance ?? 0),
|
|
13174
|
+
held: known((view?.balance ?? 0) + (view?.setAside ?? 0)),
|
|
12816
13175
|
backup: "tokens",
|
|
12817
13176
|
pending
|
|
12818
13177
|
};
|
|
@@ -13287,6 +13646,16 @@ var db = {
|
|
|
13287
13646
|
want += newest.length - page.length;
|
|
13288
13647
|
}
|
|
13289
13648
|
},
|
|
13649
|
+
/**
|
|
13650
|
+
* Every message of this profile that carries a task or a routine card, from every chat and group, oldest first (time,
|
|
13651
|
+
* then id). Reads the card index only (`CARD_INDEX`): no chat's history is read, however long. The index follows the
|
|
13652
|
+
* rows, so an edit's card, a deleted message and a deleted chat or group are in step with no bookkeeping.
|
|
13653
|
+
*/
|
|
13654
|
+
async getCardMessages() {
|
|
13655
|
+
const index = (await store(STORES.messages, "readonly")).index(CARD_INDEX);
|
|
13656
|
+
const [tasks, routines] = await Promise.all([wrap(index.getAll("task")), wrap(index.getAll("routine"))]);
|
|
13657
|
+
return [...tasks, ...routines].sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
13658
|
+
},
|
|
13290
13659
|
/** Returns false when the message was already stored. */
|
|
13291
13660
|
async addMessage(message) {
|
|
13292
13661
|
const tx = (await openDb()).transaction(STORES.messages, "readwrite");
|
|
@@ -13771,6 +14140,16 @@ var FAREWELL_SAY_MS = 1e4;
|
|
|
13771
14140
|
* a returning member whose clock runs behind the admin's is told all the same.
|
|
13772
14141
|
*/
|
|
13773
14142
|
var FAREWELL_SKEW_MS = 3e5;
|
|
14143
|
+
/**
|
|
14144
|
+
* A hub dates its beacon entry by its own clock, and a reader judges it by its own. A hub's time closer than this to
|
|
14145
|
+
* what this app saw is taken as written; past it the two clocks differ, and the entry is read in this app's time
|
|
14146
|
+
* (`inMyTime`). Half as long again as a hub's pace: what an entry may trail a reading by, between two clocks that agree.
|
|
14147
|
+
*/
|
|
14148
|
+
var HUB_CLOCK_SLACK_MS = COMMUNITY_TOPOLOGY.beaconEveryMs * 1.5;
|
|
14149
|
+
/** Two readings of the beacon no further apart than this say when an entry that moved between them was written. */
|
|
14150
|
+
var HUB_CLOCK_READ_GAP_MS = 45e3;
|
|
14151
|
+
/** A hub's clock is remembered this long after its entry was last in the beacon. */
|
|
14152
|
+
var HUB_CLOCK_KEPT_MS = 6e5;
|
|
13774
14153
|
/** A member that opens the group's link again knocks to check it is still in, this long at most (`joinByLink`). */
|
|
13775
14154
|
var CHECK_MS = 18e4;
|
|
13776
14155
|
var MESSAGE_LINK$1 = (groupId) => `group:${groupId}`;
|
|
@@ -13841,7 +14220,8 @@ var Communities = class {
|
|
|
13841
14220
|
}
|
|
13842
14221
|
/** Is this device a hub of the group? (tests and the load harness) */
|
|
13843
14222
|
isHub(groupId) {
|
|
13844
|
-
|
|
14223
|
+
const live = this.live.get(groupId);
|
|
14224
|
+
return !!live?.hub && live.session.status === "active";
|
|
13845
14225
|
}
|
|
13846
14226
|
async load(groups) {
|
|
13847
14227
|
for (const group of groups) {
|
|
@@ -14185,6 +14565,7 @@ var Communities = class {
|
|
|
14185
14565
|
if (live.session.status === "lost" && !group.joining) {
|
|
14186
14566
|
if (live.hub) {
|
|
14187
14567
|
live.hub = false;
|
|
14568
|
+
live.leaving = 0;
|
|
14188
14569
|
await this.publishBeacon(group.id, live, now, false).catch(() => {});
|
|
14189
14570
|
}
|
|
14190
14571
|
await this.closeEntries(group.id);
|
|
@@ -14237,6 +14618,7 @@ var Communities = class {
|
|
|
14237
14618
|
live.hub = true;
|
|
14238
14619
|
live.hubSince = now;
|
|
14239
14620
|
live.emptySince = now;
|
|
14621
|
+
live.leaving = 0;
|
|
14240
14622
|
live.knocksScanned = false;
|
|
14241
14623
|
await this.publishBeacon(groupId, live, now, true).catch(() => {});
|
|
14242
14624
|
traceJoin(groupId, "hub.elected");
|
|
@@ -14247,12 +14629,37 @@ var Communities = class {
|
|
|
14247
14629
|
if (live.hub) {
|
|
14248
14630
|
const load = this.hubLoad(groupId, live, now);
|
|
14249
14631
|
if (load > 0) live.emptySince = now;
|
|
14250
|
-
const
|
|
14251
|
-
if (
|
|
14252
|
-
live.
|
|
14253
|
-
|
|
14254
|
-
|
|
14255
|
-
|
|
14632
|
+
const idle = now - live.emptySince > this.timings.idleHubMs && now - live.hubSince > this.timings.idleHubMs;
|
|
14633
|
+
if (live.leaving) {
|
|
14634
|
+
const read = live.beaconAt > live.leaving, listed = live.beacon.some((h) => h.key === me);
|
|
14635
|
+
if (read && idle && !listed && this.canStepDown(groupId, live, now)) {
|
|
14636
|
+
live.leaving = 0;
|
|
14637
|
+
live.hub = false;
|
|
14638
|
+
const edges = this.host.edges(groupId);
|
|
14639
|
+
live.myHubs = freshHubs(this.hubs(live), now).map((h) => h.key).filter((key) => {
|
|
14640
|
+
const id = edges.get(key);
|
|
14641
|
+
return key !== me && !!id && this.host.linkReady(id, 2);
|
|
14642
|
+
}).slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
|
|
14643
|
+
for (const key of live.myHubs) {
|
|
14644
|
+
live.hubsAvoided.delete(key);
|
|
14645
|
+
live.hubWaits.delete(key);
|
|
14646
|
+
live.hubsUp.add(key);
|
|
14647
|
+
}
|
|
14648
|
+
live.members.clear();
|
|
14649
|
+
} else if (read && idle && listed && this.canStepDown(groupId, live, now)) {
|
|
14650
|
+
if (await this.publishBeacon(groupId, live, now, false).then(() => true, () => false)) live.leaving = now;
|
|
14651
|
+
} else if (read || now - live.leaving > COMMUNITY_TOPOLOGY.beaconEveryMs) {
|
|
14652
|
+
live.leaving = 0;
|
|
14653
|
+
live.stepDownAt = now + this.timings.idleHubMs + Math.floor(this.random() * this.timings.idleHubMs);
|
|
14654
|
+
}
|
|
14655
|
+
} else if (idle && now >= live.stepDownAt && this.canStepDown(groupId, live, now)) {
|
|
14656
|
+
live.stepDownAt = now + 3 * BEACON_RETRY_MS;
|
|
14657
|
+
if ((live.beaconAt === now || await this.readBeacon(groupId, live, now)) && this.canStepDown(groupId, live, now) && await this.publishBeacon(groupId, live, now, false).then(() => true, () => false)) {
|
|
14658
|
+
live.leaving = now;
|
|
14659
|
+
await this.closeEntries(groupId);
|
|
14660
|
+
}
|
|
14661
|
+
}
|
|
14662
|
+
if (!live.hub || live.leaving) {} else if (now - live.lastBeaconTry < BEACON_RETRY_MS) {} else if (now - live.lastBeaconWrite >= COMMUNITY_TOPOLOGY.beaconEveryMs || !live.beacon.some((h) => h.key === me) || now - live.lastBeaconWrite >= 5e3 && Math.abs(this.beaconLoad(groupId, live, now) - (live.beacon.find((h) => h.key === me)?.load ?? load)) >= 8) await this.publishBeacon(groupId, live, now, true).catch(() => {});
|
|
14256
14663
|
}
|
|
14257
14664
|
if (live.hub) {
|
|
14258
14665
|
for (const h of freshHubs(live.beacon, now)) if (h.ts + FAREWELL_SKEW_MS > (s.outAt(h.key) ?? Infinity)) this.wantFarewell(groupId, live, h.key, now);
|
|
@@ -14260,6 +14667,7 @@ var Communities = class {
|
|
|
14260
14667
|
this.endFarewells(live, now);
|
|
14261
14668
|
if (live.hub) {
|
|
14262
14669
|
const busy = this.admitting(groupId, live, now);
|
|
14670
|
+
if (s.state.pendingLeaves.some((r) => r.s !== me && this.recentHub(live, r.s))) live.lobbyBusyUntil = Math.max(live.lobbyBusyUntil, now + this.timings.lobbyBusyMs);
|
|
14263
14671
|
const lobbyEvery = now < live.lobbyBusyUntil && !busy ? this.timings.lobbyPollMs : this.timings.lobbyIdlePollMs;
|
|
14264
14672
|
if (now - live.lastLobbyPoll >= lobbyEvery) {
|
|
14265
14673
|
live.lastLobbyPoll = now;
|
|
@@ -14271,26 +14679,41 @@ var Communities = class {
|
|
|
14271
14679
|
const edges = this.host.edges(groupId);
|
|
14272
14680
|
const fresh = new Set(others.map((h) => h.key));
|
|
14273
14681
|
for (const [key, until] of live.hubsAvoided) if (until <= now) live.hubsAvoided.delete(key);
|
|
14682
|
+
const gone = live.myHubs.filter((key) => s.wasRemoved(key) || s.state.pendingLeaves.some((r) => r.s === key));
|
|
14683
|
+
for (const key of gone) {
|
|
14684
|
+
live.hubsAvoided.set(key, now + HUB_GRACE_MS);
|
|
14685
|
+
live.hubWaits.delete(key);
|
|
14686
|
+
live.hubsUp.delete(key);
|
|
14687
|
+
}
|
|
14274
14688
|
for (const key of live.myHubs) {
|
|
14689
|
+
if (gone.includes(key)) continue;
|
|
14275
14690
|
const id = edges.get(key);
|
|
14276
14691
|
if (id && this.host.linkReady(id, 2)) {
|
|
14277
14692
|
live.hubWaits.delete(key);
|
|
14278
14693
|
live.hubsUp.add(key);
|
|
14694
|
+
live.replacing?.delete(key);
|
|
14279
14695
|
continue;
|
|
14280
14696
|
}
|
|
14281
14697
|
const since = live.hubWaits.get(key) ?? now;
|
|
14282
14698
|
live.hubWaits.set(key, since);
|
|
14283
14699
|
const back = live.hubsUp.has(key) && !!id && !!this.host.linkBack?.(id);
|
|
14284
|
-
if (now - since > (back ? 3 : 1) * this.timings.hubWaitMs) {
|
|
14700
|
+
if (now - since > (back || live.replacing?.has(key) ? 3 : 1) * this.timings.hubWaitMs) {
|
|
14285
14701
|
live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
|
|
14286
14702
|
live.hubWaits.delete(key);
|
|
14287
14703
|
live.hubsUp.delete(key);
|
|
14704
|
+
live.replacing?.delete(key);
|
|
14288
14705
|
}
|
|
14289
14706
|
}
|
|
14290
14707
|
let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
|
|
14291
14708
|
kept = kept.slice(0, COMMUNITY_TOPOLOGY.hubsPerMember);
|
|
14292
14709
|
const picked = pickHubs(me, others, now, new Set(live.hubsAvoided.keys()));
|
|
14293
|
-
for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key))
|
|
14710
|
+
for (const key of picked) if (kept.length < Math.max(1, picked.length) && !kept.includes(key)) {
|
|
14711
|
+
kept.push(key);
|
|
14712
|
+
if (gone.length) {
|
|
14713
|
+
if (!edges.has(key)) live.expect.add(key);
|
|
14714
|
+
(live.replacing ??= /* @__PURE__ */ new Set()).add(key);
|
|
14715
|
+
}
|
|
14716
|
+
}
|
|
14294
14717
|
live.myHubs = kept;
|
|
14295
14718
|
live.forceHub = !kept.length && others.length < COMMUNITY_TOPOLOGY.maxHubs;
|
|
14296
14719
|
for (const hub of kept) {
|
|
@@ -14340,8 +14763,32 @@ var Communities = class {
|
|
|
14340
14763
|
if (id && !this.host.linkReady(id, 2)) this.host.expectPeer(id);
|
|
14341
14764
|
}
|
|
14342
14765
|
}
|
|
14766
|
+
/**
|
|
14767
|
+
* May this idle hub step down? When enough other hubs are listed and it has an edge up to one that is listed now
|
|
14768
|
+
* (a hub cut off from the others would only come back as one). It then steps down in two steps: it takes itself
|
|
14769
|
+
* out of the beacon, still a hub, and is a member only once a later reading still lists a hub it has an edge to.
|
|
14770
|
+
* Before, it was a member at once, and counted as hubs those it remembered from earlier readings. Hubs elected in
|
|
14771
|
+
* the same seconds (the members of a hub that left) were idle in the same second a minute later, and two of them
|
|
14772
|
+
* with an edge to each other only both stepped down, each counting on the other: each was the other's hub, neither
|
|
14773
|
+
* was one, and the two heard nobody else until the memory of them ran out, minutes later. And a hub whose write the
|
|
14774
|
+
* relays held back was no hub any more but stayed listed, so members went on asking it (2026-10-01).
|
|
14775
|
+
*/
|
|
14776
|
+
canStepDown(groupId, live, now) {
|
|
14777
|
+
const me = live.session.myKey, others = freshHubs(this.hubs(live), now).filter((h) => h.key !== me);
|
|
14778
|
+
if (others.length < COMMUNITY_TOPOLOGY.minHubs) return false;
|
|
14779
|
+
const edges = this.host.edges(groupId);
|
|
14780
|
+
return others.some((h) => {
|
|
14781
|
+
const id = edges.get(h.key);
|
|
14782
|
+
return !!id && this.host.linkReady(id, 2);
|
|
14783
|
+
});
|
|
14784
|
+
}
|
|
14343
14785
|
hubLoad(groupId, live, now) {
|
|
14344
14786
|
const edges = this.host.edges(groupId);
|
|
14787
|
+
if (now - live.beaconAt <= 2 * this.timings.beaconReadMs) {
|
|
14788
|
+
const hubs = new Set(freshHubs(this.hubs(live), now).map((h) => h.key));
|
|
14789
|
+
for (const [key, id] of edges) if (!hubs.has(key) && !live.members.has(key) && this.host.linkReady(id, 2) && !live.session.wasRemoved(key)) live.members.set(key, now);
|
|
14790
|
+
for (const key of [...live.members.keys()]) if (hubs.has(key)) live.members.delete(key);
|
|
14791
|
+
}
|
|
14345
14792
|
for (const [key, since] of live.members) {
|
|
14346
14793
|
const id = edges.get(key);
|
|
14347
14794
|
if (id && this.host.linkReady(id, 2)) live.members.set(key, now);
|
|
@@ -14383,6 +14830,7 @@ var Communities = class {
|
|
|
14383
14830
|
const n = live.members.size;
|
|
14384
14831
|
return n >= this.capacity(groupId, live, now, false) ? Math.max(n, COMMUNITY_TOPOLOGY.hubCapacity) : n;
|
|
14385
14832
|
}
|
|
14833
|
+
/** Reads the beacon; false when the reading did not happen (the relays held it back, or none answered). */
|
|
14386
14834
|
async readBeacon(groupId, live, now) {
|
|
14387
14835
|
live.lastBeaconRead = now;
|
|
14388
14836
|
const keys = beaconKeys(live.session.state.rv, groupId);
|
|
@@ -14391,13 +14839,63 @@ var Communities = class {
|
|
|
14391
14839
|
live.beaconFailedAt = now;
|
|
14392
14840
|
live.beaconAskedAt ||= now;
|
|
14393
14841
|
if (live.session.roster.length === 1 || now - live.beaconAskedAt >= COMMUNITY_TOPOLOGY.beaconFreshMs) live.beaconKnown = true;
|
|
14394
|
-
return;
|
|
14842
|
+
return false;
|
|
14395
14843
|
}
|
|
14396
14844
|
live.beaconFailedAt = 0;
|
|
14397
14845
|
live.beaconKnown = true;
|
|
14398
|
-
live.beacon = readBeacon(keys, records ?? []);
|
|
14846
|
+
live.beacon = this.inMyTime(live, readBeacon(keys, records ?? []), now);
|
|
14399
14847
|
live.head = readBeaconHead(keys, records ?? []);
|
|
14848
|
+
live.beaconAt = now;
|
|
14400
14849
|
this.noteHubs(live, now);
|
|
14850
|
+
return true;
|
|
14851
|
+
}
|
|
14852
|
+
/**
|
|
14853
|
+
* A reading of the beacon in this app's time. A hub dates its entry by its own clock; held against mine as it is, a
|
|
14854
|
+
* hub whose clock is a minute and a half from mine never looks fresh (its entry is "stale", or "not come yet"): a
|
|
14855
|
+
* member saw no hub and took itself for the only one, and a hub with such a clock was no hub to anyone. So a hub's
|
|
14856
|
+
* clock is learned from its entry: when the entry moved between two readings of mine, it was written between them,
|
|
14857
|
+
* and what it says the time was then, against my own, is how far the two clocks are apart. An entry of a clock
|
|
14858
|
+
* further than `HUB_CLOCK_SLACK_MS` from mine is moved by that much (its `since` with it); one closer, or one not
|
|
14859
|
+
* seen to move yet, is taken as written, as before. An entry that stops moving ages by my clock like any other.
|
|
14860
|
+
*/
|
|
14861
|
+
inMyTime(live, read, now) {
|
|
14862
|
+
const gap = now - live.beaconSeenAt;
|
|
14863
|
+
for (const h of read) {
|
|
14864
|
+
if (h.key === live.session.myKey) continue;
|
|
14865
|
+
const known = live.hubClocks.get(h.key);
|
|
14866
|
+
const moved = !!known && h.ts > known.said && live.beaconSeenAt > 0 && gap <= HUB_CLOCK_READ_GAP_MS;
|
|
14867
|
+
live.hubClocks.set(h.key, {
|
|
14868
|
+
said: Math.max(h.ts, known?.said ?? 0),
|
|
14869
|
+
ahead: moved ? now - gap / 2 - h.ts : known?.ahead,
|
|
14870
|
+
at: now
|
|
14871
|
+
});
|
|
14872
|
+
}
|
|
14873
|
+
for (const [key, clock] of live.hubClocks) if (now - clock.at > HUB_CLOCK_KEPT_MS) live.hubClocks.delete(key);
|
|
14874
|
+
live.beaconSeenAt = now;
|
|
14875
|
+
return read.map((h) => {
|
|
14876
|
+
const ahead = this.clockAhead(live, h.key);
|
|
14877
|
+
return ahead ? {
|
|
14878
|
+
...h,
|
|
14879
|
+
ts: h.ts + ahead,
|
|
14880
|
+
...h.since !== void 0 && { since: h.since + ahead }
|
|
14881
|
+
} : h;
|
|
14882
|
+
});
|
|
14883
|
+
}
|
|
14884
|
+
/** How far my clock is ahead of a hub's (ms, negative when behind), when the two differ; 0 when they agree or nothing says. */
|
|
14885
|
+
clockAhead(live, hub) {
|
|
14886
|
+
const ahead = hub === live.session.myKey ? 0 : live.hubClocks.get(hub)?.ahead ?? 0;
|
|
14887
|
+
return Math.abs(ahead) > HUB_CLOCK_SLACK_MS ? Math.round(ahead) : 0;
|
|
14888
|
+
}
|
|
14889
|
+
/** Entries in my time, as each hub wrote them: what goes back into the beacon, so no hub's entry is dated by another's clock. */
|
|
14890
|
+
asWritten(live, hubs) {
|
|
14891
|
+
return hubs.map((h) => {
|
|
14892
|
+
const ahead = this.clockAhead(live, h.key);
|
|
14893
|
+
return ahead ? {
|
|
14894
|
+
...h,
|
|
14895
|
+
ts: h.ts - ahead,
|
|
14896
|
+
...h.since !== void 0 && { since: h.since - ahead }
|
|
14897
|
+
} : h;
|
|
14898
|
+
});
|
|
14401
14899
|
}
|
|
14402
14900
|
/**
|
|
14403
14901
|
* The newest message frame I hold, for the beacon's head (WISP 9xx § Head): what a member's other profile on the
|
|
@@ -14422,7 +14920,7 @@ var Communities = class {
|
|
|
14422
14920
|
const read = await this.host.resolve(keys.identity.pubKeyZ32, true).catch(() => void 0);
|
|
14423
14921
|
if (read === void 0 && live.session.roster.length > 1) throw new Error("The beacon could not be read; not published");
|
|
14424
14922
|
const records = read ?? [];
|
|
14425
|
-
existing = readBeacon(keys, records);
|
|
14923
|
+
existing = this.inMyTime(live, readBeacon(keys, records), now);
|
|
14426
14924
|
head = readBeaconHead(keys, records);
|
|
14427
14925
|
}
|
|
14428
14926
|
const hubs = mergeBeacon(existing, live.session.myKey, listed ? {
|
|
@@ -14432,12 +14930,12 @@ var Communities = class {
|
|
|
14432
14930
|
since: live.hubSince || now
|
|
14433
14931
|
} : null, now, (key) => !live.session.wasRemoved(key));
|
|
14434
14932
|
const newest = newerHead(head, this.newestFrame(live), now);
|
|
14435
|
-
await this.host.publish(keys.identity, beaconRecords(keys, hubs, newest), true);
|
|
14933
|
+
await this.host.publish(keys.identity, beaconRecords(keys, this.asWritten(live, hubs), newest), true);
|
|
14436
14934
|
live.beacon = hubs;
|
|
14437
14935
|
live.head = newest;
|
|
14438
14936
|
this.noteHubs(live, now);
|
|
14439
14937
|
live.lastBeaconWrite = now;
|
|
14440
|
-
live.lastBeaconRead = now;
|
|
14938
|
+
live.lastBeaconRead = live.beaconAt = now;
|
|
14441
14939
|
live.beaconFailedAt = 0;
|
|
14442
14940
|
live.beaconKnown = true;
|
|
14443
14941
|
}
|
|
@@ -14515,10 +15013,11 @@ var Communities = class {
|
|
|
14515
15013
|
async askHub(groupId, live, hub, now) {
|
|
14516
15014
|
const keys = lobbyKeys(live.session.state.rv, groupId, hub);
|
|
14517
15015
|
const existing = readLobby(keys, await this.host.resolve(keys.identity.pubKeyZ32, true) ?? []);
|
|
15016
|
+
const theirs = now - this.clockAhead(live, hub);
|
|
14518
15017
|
await this.host.publish(keys.identity, lobbyRecords(keys, mergeLobby(existing, {
|
|
14519
15018
|
key: live.session.myKey,
|
|
14520
|
-
ts:
|
|
14521
|
-
},
|
|
15019
|
+
ts: theirs
|
|
15020
|
+
}, theirs)), true);
|
|
14522
15021
|
traceJoin(groupId, "lobby.written");
|
|
14523
15022
|
}
|
|
14524
15023
|
async pollLobby(groupId, live, now) {
|
|
@@ -14712,9 +15211,15 @@ var Communities = class {
|
|
|
14712
15211
|
this.host.emit();
|
|
14713
15212
|
}
|
|
14714
15213
|
}
|
|
15214
|
+
/**
|
|
15215
|
+
* The entry sessions this device runs for joiners go (it is no door any more). Its own, when it is knocking at the
|
|
15216
|
+
* link itself, stays: a hub taken out of the group that opened the link again at once had that session closed by
|
|
15217
|
+
* its next tick (it stops being a hub and a door there), and knocked for good with nothing for a member to answer on.
|
|
15218
|
+
*/
|
|
14715
15219
|
async closeEntries(groupId) {
|
|
14716
|
-
this.live.get(groupId)
|
|
14717
|
-
|
|
15220
|
+
const live = this.live.get(groupId), mine = this.stored.get(groupId)?.joining?.linkId;
|
|
15221
|
+
live?.pendingEntries.clear();
|
|
15222
|
+
for (const linkId of this.host.entries(groupId).values()) if (linkId !== mine) await this.host.closeEdge(linkId);
|
|
14718
15223
|
}
|
|
14719
15224
|
/** An entry session of a community came up with groups v2 on both sides: the hub invites. */
|
|
14720
15225
|
entryReady(groupId, linkId, peer) {
|
|
@@ -14824,6 +15329,12 @@ var Communities = class {
|
|
|
14824
15329
|
live.expect.add(joining.inviter);
|
|
14825
15330
|
live.lobbyWrites.set(joining.inviter, now);
|
|
14826
15331
|
live.joinedAt = now;
|
|
15332
|
+
const admission = [...joined.state.chain].reverse().find((c) => c.k === "add" && c.s === live.session.myKey);
|
|
15333
|
+
if (admission?.by === joining.inviter) live.hubClocks.set(joining.inviter, {
|
|
15334
|
+
said: 0,
|
|
15335
|
+
ahead: now - admission.ts,
|
|
15336
|
+
at: now
|
|
15337
|
+
});
|
|
14827
15338
|
await this.store.putGroup(member);
|
|
14828
15339
|
if (!again) await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE_COMMUNITY}`, now, joined.state.chain.length - 1);
|
|
14829
15340
|
else if (back) await this.event(g, "joined", "You joined again", now, joined.state.chain.length - 1);
|
|
@@ -14881,14 +15392,16 @@ var Communities = class {
|
|
|
14881
15392
|
if (live.relayed.size > RELAYED_KEPT) live.relayed.delete(live.relayed.values().next().value);
|
|
14882
15393
|
return true;
|
|
14883
15394
|
}
|
|
15395
|
+
/** True when the edge took the frame. */
|
|
14884
15396
|
sendTo(linkId, frame) {
|
|
14885
|
-
if (!linkId || !this.host.linkReady(linkId, 2)) return;
|
|
15397
|
+
if (!linkId || !this.host.linkReady(linkId, 2)) return false;
|
|
14886
15398
|
try {
|
|
14887
15399
|
this.host.sendOnLink(linkId, frame);
|
|
14888
15400
|
} catch {
|
|
14889
|
-
return;
|
|
15401
|
+
return false;
|
|
14890
15402
|
}
|
|
14891
15403
|
this.noteTaken(frame);
|
|
15404
|
+
return true;
|
|
14892
15405
|
}
|
|
14893
15406
|
/** My frame went out on an edge (at first, or in a catch-up): the message it is, or the edit it carries, was taken. */
|
|
14894
15407
|
noteTaken(frame) {
|
|
@@ -14923,9 +15436,7 @@ var Communities = class {
|
|
|
14923
15436
|
group.community = next;
|
|
14924
15437
|
await this.store.putGroup(group);
|
|
14925
15438
|
},
|
|
14926
|
-
broadcast: (frame) =>
|
|
14927
|
-
for (const [, linkId] of this.hearers(id, frame)) this.sendTo(linkId, frame);
|
|
14928
|
-
},
|
|
15439
|
+
broadcast: (frame) => this.hearers(id, frame).filter(([, linkId]) => this.sendTo(linkId, frame)).length,
|
|
14929
15440
|
direct: (to, frame) => {
|
|
14930
15441
|
if (this.mayHear(this.live.get(id), to, frame)) this.sendTo(this.host.edges(id).get(to), frame);
|
|
14931
15442
|
},
|
|
@@ -14956,7 +15467,7 @@ var Communities = class {
|
|
|
14956
15467
|
...m.forwarded && { forwarded: m.forwarded },
|
|
14957
15468
|
...m.card && { card: m.card }
|
|
14958
15469
|
});
|
|
14959
|
-
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, this.now());
|
|
15470
|
+
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
|
|
14960
15471
|
this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, came));
|
|
14961
15472
|
if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
|
|
14962
15473
|
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
|
|
@@ -14985,10 +15496,15 @@ var Communities = class {
|
|
|
14985
15496
|
hub: false,
|
|
14986
15497
|
hubSince: 0,
|
|
14987
15498
|
beacon: [],
|
|
15499
|
+
hubClocks: /* @__PURE__ */ new Map(),
|
|
15500
|
+
beaconSeenAt: 0,
|
|
14988
15501
|
head: null,
|
|
14989
15502
|
lastBeaconRead: 0,
|
|
14990
15503
|
beaconKnown: false,
|
|
14991
15504
|
beaconFailedAt: 0,
|
|
15505
|
+
beaconAt: 0,
|
|
15506
|
+
leaving: 0,
|
|
15507
|
+
stepDownAt: 0,
|
|
14992
15508
|
lastBeaconWrite: 0,
|
|
14993
15509
|
lastBeaconTry: 0,
|
|
14994
15510
|
hubCandidateAt: 0,
|
|
@@ -15474,7 +15990,7 @@ var MeshHubs = class {
|
|
|
15474
15990
|
...legacy.length ? { l: legacy } : {}
|
|
15475
15991
|
};
|
|
15476
15992
|
for (const [key, id] of edges) {
|
|
15477
|
-
if (only && key !== only || !this.host.linkReady(id)) continue;
|
|
15993
|
+
if (only && key !== only || !rosterHas(session.roster, key) || !this.host.linkReady(id)) continue;
|
|
15478
15994
|
try {
|
|
15479
15995
|
this.host.sendOnLink(id, frame);
|
|
15480
15996
|
} catch {}
|
|
@@ -15568,8 +16084,9 @@ var MeshHubs = class {
|
|
|
15568
16084
|
for (const [key, id] of this.host.edges(groupId)) {
|
|
15569
16085
|
if (key === from || !this.host.linkReady(id)) continue;
|
|
15570
16086
|
const member = rosterHas(session.roster, key);
|
|
16087
|
+
const until = member ? Infinity : removalEpoch(session.state.chain, key);
|
|
15571
16088
|
for (const frame of frames) {
|
|
15572
|
-
if (authorOf(frame) === key || !member && frame.t !== "group-commit") continue;
|
|
16089
|
+
if (authorOf(frame) === key || !member && (frame.t !== "group-commit" || frame.commit.e > until)) continue;
|
|
15573
16090
|
try {
|
|
15574
16091
|
this.host.sendOnLink(id, frame);
|
|
15575
16092
|
sent++;
|
|
@@ -15581,6 +16098,14 @@ var MeshHubs = class {
|
|
|
15581
16098
|
return sent;
|
|
15582
16099
|
}
|
|
15583
16100
|
};
|
|
16101
|
+
/**
|
|
16102
|
+
* The epoch of the commit that took `key` out of the group for the last time: the one after the last roster it was
|
|
16103
|
+
* in. -1 for a key the chain never had. Someone out of the roster is sent no commit past it.
|
|
16104
|
+
*/
|
|
16105
|
+
function removalEpoch(chain, key) {
|
|
16106
|
+
for (let e = chain.length - 1; e >= 0; e--) if (rosterHas(chain[e].m, key)) return e + 1;
|
|
16107
|
+
return -1;
|
|
16108
|
+
}
|
|
15584
16109
|
function authorOf(frame) {
|
|
15585
16110
|
switch (frame.t) {
|
|
15586
16111
|
case "group-msg":
|
|
@@ -15668,7 +16193,7 @@ var GroupTypings = class {
|
|
|
15668
16193
|
* knocking, and stayed "invited" for 10 minutes (2026-09-30).
|
|
15669
16194
|
*/
|
|
15670
16195
|
function otherEndSeen(presence, dataLink, now = Date.now()) {
|
|
15671
|
-
return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presence
|
|
16196
|
+
return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presenceSeenAt(presence) < 3e4;
|
|
15672
16197
|
}
|
|
15673
16198
|
/** The key an edit of message `id` goes by in `FramesTaken`. */
|
|
15674
16199
|
var editKey = (id, e) => `${id}#e${e}`;
|
|
@@ -15721,6 +16246,13 @@ function eventTime(taken, groupId, timestamp) {
|
|
|
15721
16246
|
return timestamp;
|
|
15722
16247
|
}
|
|
15723
16248
|
/**
|
|
16249
|
+
* The moment something arrives, for the unread marks: this device's clock, never behind it. The engine's own clock is
|
|
16250
|
+
* its last tick's time, up to a second old, while the page marks a group read by the device's clock as it is looked at:
|
|
16251
|
+
* a message that came within that second of leaving the group counted as come before it was read, and the list showed
|
|
16252
|
+
* nothing new. (A simulation's clock runs ahead of the device's: it stands.)
|
|
16253
|
+
*/
|
|
16254
|
+
var arrivalNow = (tick) => Math.max(tick, Date.now());
|
|
16255
|
+
/**
|
|
15724
16256
|
* Keeps on the group's record when a member's message came, where that is later than what it holds: the record is
|
|
15725
16257
|
* saved with the session right after (a message taken moves its state), so a message handed to me late still makes
|
|
15726
16258
|
* the group unread after the app starts again. Read from the history alone, by its own time, it sorted among what I
|
|
@@ -15763,12 +16295,19 @@ var REFUSED_FOR_MS = 6e5;
|
|
|
15763
16295
|
* doubling with each timeout in a row up to `REFUSED_FOR_MS`: a slow network is no refusal.
|
|
15764
16296
|
*/
|
|
15765
16297
|
var ENTRY_RETRY_MS = 3e4;
|
|
15766
|
-
/**
|
|
16298
|
+
/**
|
|
16299
|
+
* The welcome is on its way when the admin sends it; the session stays up a little for it to arrive. A joiner keeps
|
|
16300
|
+
* its side as long (unless its edge to the admin is up first): the admin says over it what it commits meanwhile.
|
|
16301
|
+
*/
|
|
15767
16302
|
var ENTRY_LINGER_MS = 2e4;
|
|
15768
16303
|
/** Taken out of a group, my edges stay this long: as a hub, the commit that removed me is passed on over them. */
|
|
15769
16304
|
var REMOVED_LINGER_MS = 15e3;
|
|
15770
16305
|
/** How long the tombstone of a group I left waits for the admin to hear it. */
|
|
15771
16306
|
var LEFT_KEPT_MS = 6048e5;
|
|
16307
|
+
/** Syncs of a removed member answered with the commits it lacks, per farewell: it needs one. */
|
|
16308
|
+
var FAREWELL_ANSWERS = 3;
|
|
16309
|
+
/** Commits sent to a removed member that was behind when it left, at most. */
|
|
16310
|
+
var FAREWELL_COMMITS = 64;
|
|
15772
16311
|
/** Where a member's gossip turns start among the members it is connected to: a number of its own, from its key. */
|
|
15773
16312
|
var gossipStart = (key) => {
|
|
15774
16313
|
let n = 0;
|
|
@@ -15853,6 +16392,15 @@ var Groups = class {
|
|
|
15853
16392
|
knockRewrites = /* @__PURE__ */ new Map();
|
|
15854
16393
|
/** Per group, members met over their admission a moment ago: their edge is opened expecting them. */
|
|
15855
16394
|
justMet = /* @__PURE__ */ new Map();
|
|
16395
|
+
/**
|
|
16396
|
+
* Admin side: joiners let in through the link whose entry session is still open, per group: member key → link id.
|
|
16397
|
+
* Until the edge to one of them is up, a commit made meanwhile (the next joiner's admission) goes to it over that
|
|
16398
|
+
* session: it would otherwise seal what it writes for a roster that is no longer the group's, and whoever was let
|
|
16399
|
+
* in since could never read it (two people open the link together, both say hello, one never sees the other's).
|
|
16400
|
+
*/
|
|
16401
|
+
welcomed = /* @__PURE__ */ new Map();
|
|
16402
|
+
/** Joiner side: the entry session I was let in through, kept until my edge to the admin is up or `until`. */
|
|
16403
|
+
lingering = /* @__PURE__ */ new Map();
|
|
15856
16404
|
/** Joiner side: groups whose knock is published, for the stage the joiner is shown. */
|
|
15857
16405
|
knocked = /* @__PURE__ */ new Set();
|
|
15858
16406
|
/**
|
|
@@ -16216,6 +16764,8 @@ var Groups = class {
|
|
|
16216
16764
|
delete group.entry;
|
|
16217
16765
|
this.invited.delete(groupId);
|
|
16218
16766
|
this.pendingEntries.delete(groupId);
|
|
16767
|
+
this.welcomed.delete(groupId);
|
|
16768
|
+
this.lingering.delete(groupId);
|
|
16219
16769
|
this.lastMessageAt.delete(groupId);
|
|
16220
16770
|
this.lastPeerMessageAt.delete(groupId);
|
|
16221
16771
|
this.lastMentionAt.delete(groupId);
|
|
@@ -16248,14 +16798,127 @@ var Groups = class {
|
|
|
16248
16798
|
}
|
|
16249
16799
|
async remove(groupId, key) {
|
|
16250
16800
|
if (this.isCommunity(groupId)) return this.communities.remove(groupId, key);
|
|
16251
|
-
|
|
16252
|
-
const
|
|
16801
|
+
const session = this.session(groupId), group = this.stored.get(groupId);
|
|
16802
|
+
const edge = this.host.edges(groupId).get(key), edgeUp = !!edge && this.host.linkReady(edge);
|
|
16803
|
+
const noted = !edgeUp && session.isAdmin && rosterHas(session.roster, key) && key !== session.myKey;
|
|
16804
|
+
if (noted) {
|
|
16805
|
+
this.noteFarewell(group, key, session.epoch + 1);
|
|
16806
|
+
if (edge && this.host.linkOpen?.(edge)) group.farewells[key].opened = this.now();
|
|
16807
|
+
}
|
|
16808
|
+
try {
|
|
16809
|
+
await session.remove(key);
|
|
16810
|
+
} catch (error) {
|
|
16811
|
+
if (noted) this.dropFarewell(group, key);
|
|
16812
|
+
throw error;
|
|
16813
|
+
}
|
|
16814
|
+
const contact = group.contacts?.[key];
|
|
16815
|
+
let told = edgeUp;
|
|
16253
16816
|
if (contact) try {
|
|
16254
16817
|
this.host.sendOnLink(contact, {
|
|
16255
16818
|
t: "group-removed",
|
|
16256
16819
|
g: groupId
|
|
16257
16820
|
});
|
|
16821
|
+
told = true;
|
|
16822
|
+
} catch {}
|
|
16823
|
+
if (noted && told) this.dropFarewell(group, key);
|
|
16824
|
+
if (noted) {
|
|
16825
|
+
await this.store.putGroup(group);
|
|
16826
|
+
this.reconcileEdges(groupId);
|
|
16827
|
+
}
|
|
16828
|
+
}
|
|
16829
|
+
/** A member removed while it cannot be told: its edge stays until it opens (see `StoredGroup.farewells`). The oldest go past `MAX_FAREWELLS`. */
|
|
16830
|
+
noteFarewell(group, key, e) {
|
|
16831
|
+
const kept = Object.entries(group.farewells ?? {}).filter(([k]) => k !== key).sort(([, a], [, b]) => b.at - a.at).slice(0, 7);
|
|
16832
|
+
group.farewells = Object.fromEntries([...kept, [key, {
|
|
16833
|
+
at: this.now(),
|
|
16834
|
+
e
|
|
16835
|
+
}]]);
|
|
16836
|
+
}
|
|
16837
|
+
dropFarewell(group, key) {
|
|
16838
|
+
if (!group.farewells?.[key]) return;
|
|
16839
|
+
const { [key]: _gone, ...rest } = group.farewells;
|
|
16840
|
+
group.farewells = Object.keys(rest).length ? rest : void 0;
|
|
16841
|
+
}
|
|
16842
|
+
/** The removed members this admin still waits for: not told yet, or told a moment ago (the commit is on its way). */
|
|
16843
|
+
farewellKeys(group, session, now) {
|
|
16844
|
+
if (!group?.farewells || session.status !== "active") return [];
|
|
16845
|
+
return Object.entries(group.farewells).filter(([key, f]) => !rosterHas(session.roster, key) && now - f.at <= 6048e5 && (f.told === void 0 || now - f.told < REMOVED_LINGER_MS) && (f.opened === void 0 || now - f.opened < 6e4)).map(([key]) => key);
|
|
16846
|
+
}
|
|
16847
|
+
/** The edges an active member keeps: what the roster (or the hubs) ask for, and those to members it removed while they were away. */
|
|
16848
|
+
edgesWanted(groupId, session, group, now) {
|
|
16849
|
+
const wanted = this.hubs.edgesWanted(groupId, session, group, now), farewells = this.farewellKeys(group, session, now);
|
|
16850
|
+
return farewells.length ? /* @__PURE__ */ new Set([...wanted, ...farewells]) : wanted;
|
|
16851
|
+
}
|
|
16852
|
+
/** A member key in the roster of a private group I am active in (a community: its own engine answers). */
|
|
16853
|
+
inRoster(groupId, key) {
|
|
16854
|
+
const session = this.sessions.get(groupId);
|
|
16855
|
+
return !!session && session.status === "active" && rosterHas(session.roster, key);
|
|
16856
|
+
}
|
|
16857
|
+
/** A key out of the roster of a private group I am active in: a former member, or a stranger. */
|
|
16858
|
+
outsider(groupId, key) {
|
|
16859
|
+
const session = this.sessions.get(groupId);
|
|
16860
|
+
return !!session && session.status === "active" && !rosterHas(session.roster, key);
|
|
16861
|
+
}
|
|
16862
|
+
/**
|
|
16863
|
+
* Whether a frame may go over a private group's edge to `key`. To a member, anything. To someone out of the roster
|
|
16864
|
+
* of a group I am active in, only the commits up to the one that took it out: an edge to a former member exists a
|
|
16865
|
+
* moment (its last commit is on its way) or is kept to tell it (`StoredGroup.farewells`), and carries nothing else.
|
|
16866
|
+
*/
|
|
16867
|
+
edgeAllows(groupId, key, frame) {
|
|
16868
|
+
const session = this.sessions.get(groupId);
|
|
16869
|
+
if (!session || session.status !== "active" || rosterHas(session.roster, key)) return true;
|
|
16870
|
+
const commit = frame && typeof frame === "object" && frame.t === "group-commit" ? frame.commit : void 0;
|
|
16871
|
+
return typeof commit?.e === "number" && commit.e <= removalEpoch(session.state.chain, key);
|
|
16872
|
+
}
|
|
16873
|
+
/** The connection of an edge opened. One kept to tell a removed member closes a while after, whatever its app says or does not say. */
|
|
16874
|
+
edgeOpen(groupId, peerKey) {
|
|
16875
|
+
const group = this.stored.get(groupId), farewell = group?.farewells?.[peerKey];
|
|
16876
|
+
if (!group || !farewell || farewell.opened !== void 0) return;
|
|
16877
|
+
farewell.opened = this.now();
|
|
16878
|
+
this.store.putGroup(group).catch(() => {});
|
|
16879
|
+
}
|
|
16880
|
+
/**
|
|
16881
|
+
* The edge to a member I removed while it was away came up: the commit that removed it goes now, which is all this
|
|
16882
|
+
* edge carries. Its app applies it when it had followed the chain to the commit before; one further behind says
|
|
16883
|
+
* where it is in its sync, and gets the commits between (`farewellSync`). No secret goes with any of them.
|
|
16884
|
+
*/
|
|
16885
|
+
sayFarewell(groupId, session, group, key, linkId) {
|
|
16886
|
+
const farewell = group.farewells[key], commit = session.state.chain[farewell.e];
|
|
16887
|
+
if (!commit) {
|
|
16888
|
+
this.dropFarewell(group, key);
|
|
16889
|
+
return;
|
|
16890
|
+
}
|
|
16891
|
+
try {
|
|
16892
|
+
this.host.sendOnLink(linkId, {
|
|
16893
|
+
t: "group-commit",
|
|
16894
|
+
g: groupId,
|
|
16895
|
+
commit
|
|
16896
|
+
});
|
|
16897
|
+
} catch {
|
|
16898
|
+
return;
|
|
16899
|
+
}
|
|
16900
|
+
farewell.told ??= this.now();
|
|
16901
|
+
this.store.putGroup(group).catch(() => {});
|
|
16902
|
+
}
|
|
16903
|
+
/** The sync of a removed member I wait for: the commits it lacks up to the one that removed it, a few times at most. */
|
|
16904
|
+
farewellSync(groupId, session, group, key, frame) {
|
|
16905
|
+
const farewell = group.farewells[key], linkId = this.host.edges(groupId).get(key);
|
|
16906
|
+
const from = frame.e;
|
|
16907
|
+
if (!linkId || frame.t !== "group-sync" || !Number.isSafeInteger(from) || from < 0 || from >= farewell.e - 1) return;
|
|
16908
|
+
if ((farewell.answered ?? 0) >= FAREWELL_ANSWERS) return;
|
|
16909
|
+
farewell.answered = (farewell.answered ?? 0) + 1;
|
|
16910
|
+
try {
|
|
16911
|
+
for (let e = Math.max(from + 1, farewell.e - FAREWELL_COMMITS); e <= farewell.e; e++) {
|
|
16912
|
+
const commit = session.state.chain[e];
|
|
16913
|
+
if (commit) this.host.sendOnLink(linkId, {
|
|
16914
|
+
t: "group-commit",
|
|
16915
|
+
g: groupId,
|
|
16916
|
+
commit
|
|
16917
|
+
});
|
|
16918
|
+
}
|
|
16258
16919
|
} catch {}
|
|
16920
|
+
farewell.told ??= this.now();
|
|
16921
|
+
this.store.putGroup(group).catch(() => {});
|
|
16259
16922
|
}
|
|
16260
16923
|
makeAdmin(groupId, key) {
|
|
16261
16924
|
return this.isCommunity(groupId) ? this.communities.makeAdmin(groupId, key) : this.session(groupId).transferAdmin(key);
|
|
@@ -16297,6 +16960,8 @@ var Groups = class {
|
|
|
16297
16960
|
this.knockCheckAt.delete(groupId);
|
|
16298
16961
|
this.knockRewrites.delete(groupId);
|
|
16299
16962
|
this.justMet.delete(groupId);
|
|
16963
|
+
this.welcomed.delete(groupId);
|
|
16964
|
+
this.lingering.delete(groupId);
|
|
16300
16965
|
this.relayAsked.delete(groupId);
|
|
16301
16966
|
this.hubs.forget(groupId);
|
|
16302
16967
|
this.removedAt.delete(groupId);
|
|
@@ -16428,6 +17093,7 @@ var Groups = class {
|
|
|
16428
17093
|
}
|
|
16429
17094
|
for (const [key, until] of this.refused) if (until <= now) this.refused.delete(key);
|
|
16430
17095
|
for (const [key, { at }] of this.entryTimeouts) if (now - at > 36e5) this.entryTimeouts.delete(key);
|
|
17096
|
+
for (const [groupId, kept] of this.lingering) if (now >= kept.until) await this.endLinger(groupId);
|
|
16431
17097
|
this.meshTick(now);
|
|
16432
17098
|
await this.communities.tick(now);
|
|
16433
17099
|
await this.hubsTick(now);
|
|
@@ -16449,7 +17115,7 @@ var Groups = class {
|
|
|
16449
17115
|
if (!group || group.left || session.status !== "active" || !this.hubs.large(session)) continue;
|
|
16450
17116
|
await this.hubs.tick(groupId, session, group, now).catch(() => false);
|
|
16451
17117
|
if (this.hubReconcile.has(groupId)) continue;
|
|
16452
|
-
const wanted = this.
|
|
17118
|
+
const wanted = this.edgesWanted(groupId, session, group, now);
|
|
16453
17119
|
const edges = this.host.edges(groupId);
|
|
16454
17120
|
if ([...wanted].some((key) => !edges.has(key)) || [...edges.keys()].some((key) => !wanted.has(key))) {
|
|
16455
17121
|
this.hubReconcile.add(groupId);
|
|
@@ -16496,8 +17162,16 @@ var Groups = class {
|
|
|
16496
17162
|
seedB64: group.entry.seedB64
|
|
16497
17163
|
} : void 0;
|
|
16498
17164
|
}
|
|
17165
|
+
/** Joiner side: the entry session kept after the welcome goes (the edge to the admin is up, or it was kept long enough). */
|
|
17166
|
+
async endLinger(groupId) {
|
|
17167
|
+
const kept = this.lingering.get(groupId);
|
|
17168
|
+
if (!kept) return;
|
|
17169
|
+
this.lingering.delete(groupId);
|
|
17170
|
+
if ([...this.host.entries(groupId).values()].includes(kept.linkId)) await this.host.closeEdge(kept.linkId);
|
|
17171
|
+
}
|
|
16499
17172
|
async closeEntries(groupId) {
|
|
16500
17173
|
this.pendingEntries.delete(groupId);
|
|
17174
|
+
this.welcomed.delete(groupId);
|
|
16501
17175
|
const invited = this.invited.get(groupId);
|
|
16502
17176
|
for (const linkId of this.host.entries(groupId).values()) {
|
|
16503
17177
|
invited?.delete(linkId);
|
|
@@ -16650,7 +17324,11 @@ var Groups = class {
|
|
|
16650
17324
|
this.entryTimeouts.delete(entryPeer);
|
|
16651
17325
|
this.pendingEntries.get(g)?.delete(entryPeer);
|
|
16652
17326
|
this.host.entryDone?.(linkId);
|
|
17327
|
+
let welcomed = this.welcomed.get(g);
|
|
17328
|
+
if (!welcomed) this.welcomed.set(g, welcomed = /* @__PURE__ */ new Map());
|
|
17329
|
+
welcomed.set(entryPeer, linkId);
|
|
16653
17330
|
setTimeout(() => {
|
|
17331
|
+
if (this.welcomed.get(g)?.get(entryPeer) === linkId) this.welcomed.get(g).delete(entryPeer);
|
|
16654
17332
|
if (this.host.entries(g).get(entryPeer) === linkId) this.host.closeEdge(linkId);
|
|
16655
17333
|
}, ENTRY_LINGER_MS);
|
|
16656
17334
|
}
|
|
@@ -16698,6 +17376,11 @@ var Groups = class {
|
|
|
16698
17376
|
};
|
|
16699
17377
|
this.stored.set(g, member);
|
|
16700
17378
|
this.attach(joined.state);
|
|
17379
|
+
if (viaLink) this.lingering.set(g, {
|
|
17380
|
+
linkId,
|
|
17381
|
+
admin: group.invitation.admin,
|
|
17382
|
+
until: this.now() + ENTRY_LINGER_MS
|
|
17383
|
+
});
|
|
16701
17384
|
await this.store.putGroup(member);
|
|
16702
17385
|
if (!viaLink) await this.sessions.get(g).setNick(group.invitation.admin, this.host.contactName(linkId));
|
|
16703
17386
|
await this.event(g, "joined", `You joined. ${GROUP_READ_NOTE}`, Date.now(), joined.state.chain.length - 1);
|
|
@@ -16705,12 +17388,18 @@ var Groups = class {
|
|
|
16705
17388
|
if (viaLink) {
|
|
16706
17389
|
this.lastKnock.delete(g);
|
|
16707
17390
|
this.knocked.delete(g);
|
|
16708
|
-
await this.host.closeEdge(linkId);
|
|
16709
17391
|
}
|
|
16710
17392
|
this.reconcileEdges(g);
|
|
16711
17393
|
this.host.emit();
|
|
16712
17394
|
return;
|
|
16713
17395
|
}
|
|
17396
|
+
case "group-commit": {
|
|
17397
|
+
const kept = this.lingering.get(g), session = this.sessions.get(g);
|
|
17398
|
+
if (!kept || kept.linkId !== linkId || !session) return;
|
|
17399
|
+
const taken = await session.handle(kept.admin, frame);
|
|
17400
|
+
if (taken.length) this.hubs.passOn(g, session, kept.admin, taken);
|
|
17401
|
+
return;
|
|
17402
|
+
}
|
|
16714
17403
|
case "group-removed": {
|
|
16715
17404
|
const session = this.sessions.get(g);
|
|
16716
17405
|
const group = this.stored.get(g);
|
|
@@ -16738,6 +17427,10 @@ var Groups = class {
|
|
|
16738
17427
|
}
|
|
16739
17428
|
}
|
|
16740
17429
|
}
|
|
17430
|
+
/** A key the group's chain had as a member and has no more (not one it never had: a member my chain has not reached yet). */
|
|
17431
|
+
tookOut(session, key) {
|
|
17432
|
+
return !rosterHas(session.roster, key) && removalEpoch(session.state.chain, key) >= 0;
|
|
17433
|
+
}
|
|
16741
17434
|
/** A `group-*` frame on an edge: from the member the edge is pinned to. */
|
|
16742
17435
|
async handleEdgeFrame(groupId, peerKey, frame) {
|
|
16743
17436
|
if (this.isCommunity(groupId)) return this.communities.handleEdgeFrame(groupId, peerKey, frame);
|
|
@@ -16746,6 +17439,12 @@ var Groups = class {
|
|
|
16746
17439
|
return;
|
|
16747
17440
|
}
|
|
16748
17441
|
const session = this.sessions.get(groupId);
|
|
17442
|
+
const waiting = this.stored.get(groupId);
|
|
17443
|
+
if (session && waiting?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) {
|
|
17444
|
+
if (session.status === "active" && frame && typeof frame === "object") this.farewellSync(groupId, session, waiting, peerKey, frame);
|
|
17445
|
+
return;
|
|
17446
|
+
}
|
|
17447
|
+
if (session?.status === "active" && this.tookOut(session, peerKey)) return;
|
|
16749
17448
|
if (session?.status === "active" && frame && typeof frame === "object" && frame.t === "group-here") {
|
|
16750
17449
|
this.heardHere(groupId, session, peerKey, frame);
|
|
16751
17450
|
return;
|
|
@@ -16834,6 +17533,10 @@ var Groups = class {
|
|
|
16834
17533
|
return;
|
|
16835
17534
|
}
|
|
16836
17535
|
const session = this.sessions.get(groupId), group = this.stored.get(groupId);
|
|
17536
|
+
if (session?.status === "active" && group?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) {
|
|
17537
|
+
this.sayFarewell(groupId, session, group, peerKey, linkId);
|
|
17538
|
+
return;
|
|
17539
|
+
}
|
|
16837
17540
|
if (!session || !group || session.status !== "active" || !rosterHas(session.roster, peerKey)) return;
|
|
16838
17541
|
const large = this.host.linkReady(linkId, 3) && this.markLarge(group, peerKey);
|
|
16839
17542
|
const legacy = this.hubs.enabled && this.hubs.edgeReady(groupId, session, group, peerKey, this.host.linkReady(linkId, 4), this.now());
|
|
@@ -16843,6 +17546,8 @@ var Groups = class {
|
|
|
16843
17546
|
} catch {
|
|
16844
17547
|
return;
|
|
16845
17548
|
}
|
|
17549
|
+
if (this.lingering.get(groupId)?.admin === peerKey) this.endLinger(groupId).catch(() => {});
|
|
17550
|
+
this.welcomed.get(groupId)?.delete(peerKey);
|
|
16846
17551
|
this.announceHere(groupId, session, peerKey);
|
|
16847
17552
|
this.host.edgeUp?.(groupId, peerKey);
|
|
16848
17553
|
this.host.emit();
|
|
@@ -16962,6 +17667,7 @@ var Groups = class {
|
|
|
16962
17667
|
if (reconcile) this.lastReconcile = now;
|
|
16963
17668
|
for (const [groupId, session] of this.sessions) {
|
|
16964
17669
|
if (session.status !== "active" || this.stored.get(groupId)?.left) continue;
|
|
17670
|
+
this.farewellsTick(groupId, session, now);
|
|
16965
17671
|
if (reconcile) {
|
|
16966
17672
|
const edges = this.host.edges(groupId);
|
|
16967
17673
|
if (!this.hubs.large(session) && session.others.some((key) => !edges.has(key))) this.reconcileEdges(groupId);
|
|
@@ -16985,12 +17691,25 @@ var Groups = class {
|
|
|
16985
17691
|
} catch {}
|
|
16986
17692
|
}
|
|
16987
17693
|
}
|
|
17694
|
+
/** A removed member told a moment ago, or waited for a week: its edge goes, and nothing more is kept about it. */
|
|
17695
|
+
farewellsTick(groupId, session, now) {
|
|
17696
|
+
const group = this.stored.get(groupId);
|
|
17697
|
+
if (!group?.farewells) return;
|
|
17698
|
+
const waiting = new Set(this.farewellKeys(group, session, now));
|
|
17699
|
+
const done = Object.keys(group.farewells).filter((key) => !waiting.has(key));
|
|
17700
|
+
if (!done.length) return;
|
|
17701
|
+
for (const key of done) this.dropFarewell(group, key);
|
|
17702
|
+
this.store.putGroup(group).catch(() => {});
|
|
17703
|
+
this.reconcileEdges(groupId);
|
|
17704
|
+
}
|
|
16988
17705
|
edgeNick(groupId, peerKey, nick) {
|
|
16989
17706
|
if (this.isCommunity(groupId)) {
|
|
16990
17707
|
this.communities.edgeNick(groupId, peerKey, nick);
|
|
16991
17708
|
return;
|
|
16992
17709
|
}
|
|
16993
|
-
this.sessions.get(groupId)
|
|
17710
|
+
const session = this.sessions.get(groupId);
|
|
17711
|
+
if (session && this.stored.get(groupId)?.farewells?.[peerKey] && !rosterHas(session.roster, peerKey)) return;
|
|
17712
|
+
session?.setNick(peerKey, nick);
|
|
16994
17713
|
}
|
|
16995
17714
|
session(groupId) {
|
|
16996
17715
|
const session = this.sessions.get(groupId);
|
|
@@ -17008,10 +17727,14 @@ var Groups = class {
|
|
|
17008
17727
|
},
|
|
17009
17728
|
send: (to, frame) => {
|
|
17010
17729
|
const edge = this.host.edges(state.id).get(to);
|
|
17011
|
-
if (!edge) return;
|
|
17012
17730
|
try {
|
|
17731
|
+
if (!edge) throw new Error("no edge");
|
|
17013
17732
|
this.host.sendOnLink(edge, frame);
|
|
17014
17733
|
} catch {
|
|
17734
|
+
const entry = frame.t === "group-commit" ? this.welcomed.get(state.id)?.get(to) : void 0;
|
|
17735
|
+
if (entry) try {
|
|
17736
|
+
this.host.sendOnLink(entry, frame);
|
|
17737
|
+
} catch {}
|
|
17015
17738
|
return;
|
|
17016
17739
|
}
|
|
17017
17740
|
if ((frame.t === "group-msg" || frame.t === "group-edit") && frame.s === session.myKey) {
|
|
@@ -17036,7 +17759,7 @@ var Groups = class {
|
|
|
17036
17759
|
...m.card && { card: m.card }
|
|
17037
17760
|
};
|
|
17038
17761
|
const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
|
|
17039
|
-
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, this.now());
|
|
17762
|
+
const came = m.sender === session.myKey ? timestamp : cameAt(timestamp, stored, arrivalNow(this.now()));
|
|
17040
17763
|
this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, came));
|
|
17041
17764
|
if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
|
|
17042
17765
|
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
|
|
@@ -17110,7 +17833,7 @@ var Groups = class {
|
|
|
17110
17833
|
const existing = this.host.edges(groupId);
|
|
17111
17834
|
const left = group?.left;
|
|
17112
17835
|
const removed = this.removedAt.get(groupId);
|
|
17113
|
-
const wanted = session?.status === "active" && group ? this.
|
|
17836
|
+
const wanted = session?.status === "active" && group ? this.edgesWanted(groupId, session, group, this.now()) : left ? /* @__PURE__ */ new Set([left.admin, ...left.hubs ?? []]) : removed !== void 0 && this.now() - removed < REMOVED_LINGER_MS ? new Set(existing.keys()) : /* @__PURE__ */ new Set();
|
|
17114
17837
|
for (const [key, linkId] of existing) if (!wanted.has(key)) await this.host.closeEdge(linkId);
|
|
17115
17838
|
const met = this.justMet.get(groupId);
|
|
17116
17839
|
if (session) {
|
|
@@ -17160,7 +17883,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
|
|
|
17160
17883
|
linkId: live.stored.id,
|
|
17161
17884
|
state: "waiting",
|
|
17162
17885
|
noSlot: true,
|
|
17163
|
-
lastSeenAt: Math.max(live.presence
|
|
17886
|
+
lastSeenAt: Math.max(presenceSeenAt(live.presence), live.lastSyncAt)
|
|
17164
17887
|
};
|
|
17165
17888
|
const error = open ? void 0 : live.pairing?.status === "error" ? live.pairing.error : live.discoveryError;
|
|
17166
17889
|
const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
|
|
@@ -17168,7 +17891,7 @@ function edgeView(live, now = Date.now(), noSlot = false) {
|
|
|
17168
17891
|
linkId: live.stored.id,
|
|
17169
17892
|
state,
|
|
17170
17893
|
...open && live.pairing?.transport ? { transport: live.pairing.transport } : {},
|
|
17171
|
-
lastSeenAt: open ? now : Math.max(live.presence
|
|
17894
|
+
lastSeenAt: open ? now : Math.max(presenceSeenAt(live.presence), live.lastSyncAt),
|
|
17172
17895
|
...error ? { error } : {}
|
|
17173
17896
|
};
|
|
17174
17897
|
}
|
|
@@ -18338,7 +19061,7 @@ var HoldEngine = class {
|
|
|
18338
19061
|
name: heldName(storage.space, mailbox, seq),
|
|
18339
19062
|
bytes: item.bytes,
|
|
18340
19063
|
ts: item.timestamp,
|
|
18341
|
-
expires: now + this.ttl(),
|
|
19064
|
+
expires: now + Math.min(this.ttl(), HOLD_LIMITS.ttlMs - HOLD_DATED_BACK_MS),
|
|
18342
19065
|
state: "queued"
|
|
18343
19066
|
};
|
|
18344
19067
|
await this.save(linkId, {
|
|
@@ -18536,7 +19259,7 @@ var HoldEngine = class {
|
|
|
18536
19259
|
const now = this.now();
|
|
18537
19260
|
const held = hold.outbox.filter((e) => e.state === "held");
|
|
18538
19261
|
let manifestUrl = null;
|
|
18539
|
-
const
|
|
19262
|
+
const dated = now - HOLD_DATED_BACK_MS, expires = dated + HOLD_LIMITS.ttlMs;
|
|
18540
19263
|
if (held.length && storage && hold.mailbox) {
|
|
18541
19264
|
if (rewriteManifest || !hold.manifestSignedAt) {
|
|
18542
19265
|
const entries = [];
|
|
@@ -18552,7 +19275,7 @@ var HoldEngine = class {
|
|
|
18552
19275
|
mailbox: hold.mailbox,
|
|
18553
19276
|
seq: hold.pointerRev + 1,
|
|
18554
19277
|
id: `manifest-${hold.pointerRev + 1}`,
|
|
18555
|
-
ts:
|
|
19278
|
+
ts: dated,
|
|
18556
19279
|
kind: "manifest",
|
|
18557
19280
|
meta: { entries },
|
|
18558
19281
|
expires
|
|
@@ -18573,7 +19296,7 @@ var HoldEngine = class {
|
|
|
18573
19296
|
}
|
|
18574
19297
|
const pointer = {
|
|
18575
19298
|
rev: hold.pointerRev + 1,
|
|
18576
|
-
issued:
|
|
19299
|
+
issued: dated,
|
|
18577
19300
|
expires,
|
|
18578
19301
|
manifestUrl,
|
|
18579
19302
|
top: hold.outSeq,
|
|
@@ -18650,7 +19373,7 @@ var HoldEngine = class {
|
|
|
18650
19373
|
this.host.changed();
|
|
18651
19374
|
return;
|
|
18652
19375
|
}
|
|
18653
|
-
const pointer = packet ? keys.readPointer(packet
|
|
19376
|
+
const pointer = packet ? keys.readPointer(packet) : null;
|
|
18654
19377
|
if (!pointer) return;
|
|
18655
19378
|
let hold = this.state(linkId);
|
|
18656
19379
|
if (pointer.rev < hold.peerPointerRev) return;
|
|
@@ -19685,7 +20408,7 @@ var ProfilePeek = class {
|
|
|
19685
20408
|
const keys = new HoldKeys(link, link.participationSeed, link.pairedPeerKey);
|
|
19686
20409
|
const packet = await transport.resolve(keys.peerAddress, { background: true });
|
|
19687
20410
|
const now = this.now(), hold = link.hold;
|
|
19688
|
-
const pointer = packet ? keys.readPointer(packet
|
|
20411
|
+
const pointer = packet ? keys.readPointer(packet) : null;
|
|
19689
20412
|
if (pointer && pointer.rev >= hold.peerPointerRev && pointer.expires > now && pointer.top > hold.inSeq) held = Math.min(pointer.count, pointer.top - hold.inSeq);
|
|
19690
20413
|
}
|
|
19691
20414
|
return {
|
|
@@ -19960,6 +20683,10 @@ var S3Store = class {
|
|
|
19960
20683
|
*
|
|
19961
20684
|
* Secrets are random, so there is no seed to restore from yet. `exportTokens`
|
|
19962
20685
|
* is the backup, and balances are meant to stay pocket-money sized.
|
|
20686
|
+
*
|
|
20687
|
+
* Every swap is written down before the mint is asked (`PendingSwap`, or the payment intent of a reviewed payment),
|
|
20688
|
+
* with its blinded outputs and their secrets: the mint signs those outputs and no others, and its signatures can be
|
|
20689
|
+
* asked for again (NUT-09) at any time.
|
|
19963
20690
|
*/
|
|
19964
20691
|
var UNIT$1 = "sat";
|
|
19965
20692
|
var QUOTE_POLL_MS = 4e3;
|
|
@@ -19969,11 +20696,37 @@ var MAX_AMOUNT = 1e6;
|
|
|
19969
20696
|
/** How long "Get test coins" waits for the test mint to mark its invoice paid before saying the coins come later. */
|
|
19970
20697
|
var TEST_COINS_WAIT_MS = 3e4;
|
|
19971
20698
|
var HISTORY_SHOWN = 100;
|
|
20699
|
+
/** How long before a mint that could not say which restored ecash is spent is asked again. */
|
|
20700
|
+
var RESTORED_POLL_MS = 6e4;
|
|
20701
|
+
/** How long before a mint that could not say where a swap stands is asked again. */
|
|
20702
|
+
var SWAP_POLL_MS = 3e4;
|
|
20703
|
+
/** The same, for a swap that holds nothing any more or whose mint cannot give signatures back: asked now and then. */
|
|
20704
|
+
var SWAP_SLOW_POLL_MS = 6e5;
|
|
20705
|
+
/** How long a swap whose inputs were given back is still asked about, in case the mint made it after all. */
|
|
20706
|
+
var SWAP_WATCH_MS = 36e5;
|
|
19972
20707
|
function reviewedCashuFee(preview) {
|
|
19973
20708
|
const fee = preview.inputs.reduce((sum, proof) => sum + Amount.from(proof.amount).toNumber(), 0) - (preview.keepOutputs ?? []).reduce((sum, output) => sum + Amount.from(output.blindedMessage.amount).toNumber(), 0) - preview.amount.toNumber();
|
|
19974
20709
|
if (!Number.isSafeInteger(fee) || fee < 0) throw new Error("Invalid Cashu prepared fee");
|
|
19975
20710
|
return fee;
|
|
19976
20711
|
}
|
|
20712
|
+
/**
|
|
20713
|
+
* The mint was asked for a swap and has not answered. The swap is written down: the wallet asks the mint where it
|
|
20714
|
+
* stands until it knows, then stores what it brought or frees what it held. Nothing is known to have failed.
|
|
20715
|
+
*/
|
|
20716
|
+
var SwapUnsettledError = class extends Error {
|
|
20717
|
+
swap;
|
|
20718
|
+
cause;
|
|
20719
|
+
constructor(swap, cause) {
|
|
20720
|
+
const host = new URL(swap.mint).host;
|
|
20721
|
+
super(swap.kind === "receive" ? `${host} has not answered yet. The ecash is added to your wallet as soon as it does.` : `${host} has not answered yet. The payment was not made. The sats set aside for it return to your wallet a few minutes after the mint answers.`);
|
|
20722
|
+
this.swap = swap;
|
|
20723
|
+
this.cause = cause;
|
|
20724
|
+
this.name = "SwapUnsettledError";
|
|
20725
|
+
}
|
|
20726
|
+
};
|
|
20727
|
+
/** When a swap with no answer can no longer reach the mint: a request's longest life, and a margin after its last try. */
|
|
20728
|
+
var swapOverdueAt = (swap) => Math.max(swap.createdAt + CASHU_REQUEST_TIMEOUT_MS + SWAP_SETTLED_MS, (swap.attemptEndedAt ?? Number.POSITIVE_INFINITY) + SWAP_SETTLED_MS);
|
|
20729
|
+
var swapOf = (swap) => swap.swap;
|
|
19977
20730
|
function normalizeMintUrl(input) {
|
|
19978
20731
|
let url;
|
|
19979
20732
|
try {
|
|
@@ -20004,6 +20757,7 @@ var toStored = (mint, p, reserved) => ({
|
|
|
20004
20757
|
});
|
|
20005
20758
|
/** The note of the ecash "Get test coins" brought (the engine plays its own sound for it). */
|
|
20006
20759
|
var TEST_COINS_NOTE = "Test coins from the test mint";
|
|
20760
|
+
var swapId = () => `swap-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
20007
20761
|
var walletTx = (mint, kind, amount, fee, note) => ({
|
|
20008
20762
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
|
20009
20763
|
timestamp: Date.now(),
|
|
@@ -20037,8 +20791,13 @@ var CashuWallet = class {
|
|
|
20037
20791
|
locks = /* @__PURE__ */ new Map();
|
|
20038
20792
|
quoteTimer = null;
|
|
20039
20793
|
meltTimer = null;
|
|
20794
|
+
restoredTimer = null;
|
|
20795
|
+
swapTimer = null;
|
|
20796
|
+
swapTimerAt = 0;
|
|
20040
20797
|
/** Per melt quote that ended unpaid: the sats the mint kept all the same (the fee of the split before the melt). */
|
|
20041
20798
|
failedFees = /* @__PURE__ */ new Map();
|
|
20799
|
+
/** Melt quotes that ended unpaid with proofs the mint read spent: ecash spent somewhere else, said as such. */
|
|
20800
|
+
spentAway = /* @__PURE__ */ new Set();
|
|
20042
20801
|
/**
|
|
20043
20802
|
* `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
|
|
20044
20803
|
* invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
|
|
@@ -20052,6 +20811,8 @@ var CashuWallet = class {
|
|
|
20052
20811
|
start() {
|
|
20053
20812
|
this.pollQuotes();
|
|
20054
20813
|
this.pollMelts();
|
|
20814
|
+
this.checkRestored();
|
|
20815
|
+
this.pollSwaps();
|
|
20055
20816
|
for (const mint of this.getKnownMints()) this.checkMint(mint).then(() => this.events.onChange(), () => {});
|
|
20056
20817
|
}
|
|
20057
20818
|
/** The Cashu wallet of one network: its mints and their balance. History is every network's, newest first. */
|
|
@@ -20065,13 +20826,57 @@ var CashuWallet = class {
|
|
|
20065
20826
|
}));
|
|
20066
20827
|
const all = await wrap((await store(STORES.walletTx, "readonly")).getAll());
|
|
20067
20828
|
all.sort((a, b) => b.timestamp - a.timestamp);
|
|
20829
|
+
const here = new Set(mints.map((m) => m.url));
|
|
20830
|
+
const swaps = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => here.has(s.mint) && !s.released && !s.finished);
|
|
20831
|
+
const about = (s) => s.kind === "send" ? swapOf(s).inputs.reduce((sum, p) => sum + Number(p.amount), 0) : s.tx?.amount ?? 0;
|
|
20832
|
+
const spentAway = new Set(swaps.filter((s) => s.stuck && s.kind === "send").flatMap((s) => swapOf(s).inputs.map((p) => p.secret)));
|
|
20068
20833
|
return {
|
|
20069
20834
|
mints,
|
|
20070
20835
|
balance: total(mints.map((m) => ({ amount: m.balance }))),
|
|
20836
|
+
setAside: total(proofs.filter((p) => here.has(p.mint) && p.reserved && !spentAway.has(p.secret))),
|
|
20837
|
+
openSwaps: swaps.length,
|
|
20838
|
+
swapsAmount: total(swaps.map((s) => ({ amount: about(s) }))),
|
|
20839
|
+
unconfirmed: total(swaps.filter((s) => s.stuck).map((s) => ({ amount: about(s) }))),
|
|
20071
20840
|
history: all.slice(0, HISTORY_SHOWN),
|
|
20072
20841
|
feesPaid: all.reduce((sum, tx) => sum + tx.fee, 0)
|
|
20073
20842
|
};
|
|
20074
20843
|
}
|
|
20844
|
+
/**
|
|
20845
|
+
* Ecash a profile restore brought back (`unchecked`) is a copy from when the backup was made. What was spent since
|
|
20846
|
+
* (by the profile it was copied from, or by this one before it was restored) still counted in the balance, for good,
|
|
20847
|
+
* and every payment that picked one of those proofs failed with the mint's "Token already spent". Each mint is asked
|
|
20848
|
+
* once which of them it still reads unspent: a spent proof is dropped, an unspent one is checked for good, and one
|
|
20849
|
+
* the mint holds pending, or a mint that cannot be asked, is asked again later.
|
|
20850
|
+
*/
|
|
20851
|
+
async checkRestored() {
|
|
20852
|
+
if (this.restoredTimer) clearTimeout(this.restoredTimer);
|
|
20853
|
+
this.restoredTimer = null;
|
|
20854
|
+
const waiting = (p) => !!p.unchecked && !p.reserved;
|
|
20855
|
+
let again = false, changed = false;
|
|
20856
|
+
for (const mint of new Set((await this.allProofs()).filter(waiting).map((p) => p.mint))) try {
|
|
20857
|
+
await this.locked(mint, async () => {
|
|
20858
|
+
const proofs = (await this.allProofs()).filter((p) => p.mint === mint && waiting(p));
|
|
20859
|
+
if (!proofs.length) return;
|
|
20860
|
+
const states = await (await this.wallet(mint)).checkProofsStates(proofs);
|
|
20861
|
+
if (states.length !== proofs.length) throw new Error("The mint answered for another number of proofs");
|
|
20862
|
+
await transact([STORES.proofs], (stores) => {
|
|
20863
|
+
proofs.forEach((proof, i) => {
|
|
20864
|
+
const state = states[i]?.state;
|
|
20865
|
+
if (state === "SPENT") stores[STORES.proofs].delete(proof.secret);
|
|
20866
|
+
else if (state === "UNSPENT") {
|
|
20867
|
+
const { unchecked: _checked, ...kept } = proof;
|
|
20868
|
+
stores[STORES.proofs].put(kept);
|
|
20869
|
+
} else again = true;
|
|
20870
|
+
});
|
|
20871
|
+
});
|
|
20872
|
+
changed = true;
|
|
20873
|
+
});
|
|
20874
|
+
} catch {
|
|
20875
|
+
again = true;
|
|
20876
|
+
}
|
|
20877
|
+
if (changed) this.events.onChange();
|
|
20878
|
+
if (again) this.restoredTimer = setTimeout(() => void this.checkRestored(), RESTORED_POLL_MS);
|
|
20879
|
+
}
|
|
20075
20880
|
/** Talks to the mint before it is added: a typo should not become a place to keep money. */
|
|
20076
20881
|
async checkMint(url) {
|
|
20077
20882
|
const normalized = normalizeMintUrl(url);
|
|
@@ -20232,12 +21037,16 @@ var CashuWallet = class {
|
|
|
20232
21037
|
const quote = await wrap((await store(STORES.quotes, "readonly")).get(id));
|
|
20233
21038
|
if (!quote || quote.issuedUnclaimed || quote.held) return null;
|
|
20234
21039
|
const wallet = await this.wallet(quote.mint);
|
|
20235
|
-
const
|
|
21040
|
+
const answer = await wallet.checkMintQuoteBolt11(quote.quote);
|
|
21041
|
+
const { state } = answer;
|
|
20236
21042
|
if (state === "UNPAID") {
|
|
20237
21043
|
if (quote.expiresAt && quote.expiresAt + 6e4 < Date.now()) await wrap((await store(STORES.quotes, "readwrite")).delete(quote.quote));
|
|
20238
21044
|
return null;
|
|
20239
21045
|
}
|
|
21046
|
+
const saved = quote.outputs?.map((o) => OutputData.deserialize(o));
|
|
20240
21047
|
if (state === "ISSUED") {
|
|
21048
|
+
const issued = saved?.length ? await this.signedOutputs(wallet, saved) : void 0;
|
|
21049
|
+
if (Array.isArray(issued)) return this.storeMinted(quote, issued);
|
|
20241
21050
|
console.warn(`[wallet] ${quote.mint} issued quote ${quote.quote} (${quote.amount} sats) but this wallet holds no ecash for it`);
|
|
20242
21051
|
await wrap((await store(STORES.quotes, "readwrite")).put({
|
|
20243
21052
|
...quote,
|
|
@@ -20245,11 +21054,31 @@ var CashuWallet = class {
|
|
|
20245
21054
|
}));
|
|
20246
21055
|
return null;
|
|
20247
21056
|
}
|
|
20248
|
-
|
|
21057
|
+
const preview = await wallet.prepareMint("bolt11", quote.amount, {
|
|
21058
|
+
...answer,
|
|
21059
|
+
quote: quote.quote
|
|
21060
|
+
});
|
|
21061
|
+
if (saved) preview.outputData = saved;
|
|
21062
|
+
const written = {
|
|
20249
21063
|
...quote,
|
|
20250
|
-
paid: true
|
|
20251
|
-
|
|
20252
|
-
|
|
21064
|
+
paid: true,
|
|
21065
|
+
outputs: saved ? quote.outputs : preview.outputData.map((o) => OutputData.serialize(o))
|
|
21066
|
+
};
|
|
21067
|
+
if (!quote.paid || !saved) await wrap((await store(STORES.quotes, "readwrite")).put(written));
|
|
21068
|
+
let proofs;
|
|
21069
|
+
try {
|
|
21070
|
+
proofs = await wallet.completeMint(preview);
|
|
21071
|
+
} catch (error) {
|
|
21072
|
+
if (saved && isMintOperationError(error) && (await wallet.checkMintQuoteBolt11(quote.quote).catch(() => null))?.state === "PAID") {
|
|
21073
|
+
const { outputs: _refused, ...fresh } = written;
|
|
21074
|
+
await wrap((await store(STORES.quotes, "readwrite")).put(fresh));
|
|
21075
|
+
}
|
|
21076
|
+
throw error;
|
|
21077
|
+
}
|
|
21078
|
+
return this.storeMinted(quote, proofs);
|
|
21079
|
+
}
|
|
21080
|
+
/** The ecash of a paid invoice goes in with its history line, in the transaction that drops the quote. */
|
|
21081
|
+
async storeMinted(quote, proofs) {
|
|
20253
21082
|
const minted = sats(proofs);
|
|
20254
21083
|
const tx = walletTx(quote.mint, "lightning-in", minted, quote.amount - minted, quote.paymentId ? "Request paid over Lightning" : quote.testCoins ? TEST_COINS_NOTE : void 0);
|
|
20255
21084
|
await transact([
|
|
@@ -20286,24 +21115,24 @@ var CashuWallet = class {
|
|
|
20286
21115
|
for (const mint of candidates) {
|
|
20287
21116
|
if (await this.balanceAt(mint) < amount) continue;
|
|
20288
21117
|
return this.locked(mint, async () => {
|
|
20289
|
-
const
|
|
20290
|
-
const proofs = await this.proofsAt(mint);
|
|
20291
|
-
const { keep, send } = await wallet.send(amount, asProofLike(proofs), { includeFees: true });
|
|
21118
|
+
const { inputs, keep, send, swap } = await this.split(mint, amount, await this.proofsAt(mint));
|
|
20292
21119
|
const token = getEncodedToken({
|
|
20293
21120
|
mint,
|
|
20294
21121
|
proofs: send,
|
|
20295
21122
|
unit: UNIT$1
|
|
20296
21123
|
});
|
|
20297
|
-
const spent = total(
|
|
21124
|
+
const spent = total(inputs) - sats(keep);
|
|
20298
21125
|
const tx = walletTx(mint, "ecash-out", amount, spent - amount, note);
|
|
20299
21126
|
const kept = outbox?.(token, mint);
|
|
20300
21127
|
await transact([
|
|
20301
21128
|
STORES.proofs,
|
|
20302
21129
|
STORES.walletTx,
|
|
21130
|
+
STORES.swaps,
|
|
20303
21131
|
...kept ? [STORES.payments] : []
|
|
20304
21132
|
], (stores) => {
|
|
20305
|
-
this.queueReplace(stores[STORES.proofs], mint,
|
|
21133
|
+
this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
|
|
20306
21134
|
stores[STORES.walletTx].put(tx);
|
|
21135
|
+
if (swap) stores[STORES.swaps].delete(swap.id);
|
|
20307
21136
|
if (kept) stores[STORES.payments].put(kept);
|
|
20308
21137
|
});
|
|
20309
21138
|
this.events.onChange();
|
|
@@ -20366,18 +21195,41 @@ var CashuWallet = class {
|
|
|
20366
21195
|
const keep = (prepared.swap.keepOutputs ?? []).map(OutputData.deserialize);
|
|
20367
21196
|
const send = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
|
|
20368
21197
|
const outputs = [...keep, ...send];
|
|
20369
|
-
const
|
|
20370
|
-
if (
|
|
20371
|
-
|
|
20372
|
-
|
|
20373
|
-
|
|
20374
|
-
|
|
21198
|
+
const restored = await this.restoreOutputs(wallet, outputs);
|
|
21199
|
+
if (!Array.isArray(restored)) return void 0;
|
|
21200
|
+
return this.finishReviewedCashu(review, prepared, restored.slice(0, keep.length), restored.slice(keep.length));
|
|
21201
|
+
}
|
|
21202
|
+
/**
|
|
21203
|
+
* What the mint says of outputs that were written down before it was asked to sign them: the proofs they stand for
|
|
21204
|
+
* when it signed them all, `none` when it answered that it signed none, and undefined when it has no way of saying
|
|
21205
|
+
* (it does not restore, NUT-09, by its own info). Only an answer counts: a mint that cannot be reached, answers with
|
|
21206
|
+
* an error or answers for some of the outputs throws, and is asked again later.
|
|
21207
|
+
*/
|
|
21208
|
+
async signedOutputs(wallet, outputs) {
|
|
21209
|
+
if (!wallet.getMintInfo().isSupported(9).supported) return void 0;
|
|
21210
|
+
const restored = await this.restoreOutputs(wallet, outputs);
|
|
21211
|
+
if (restored === "some") throw new Error("The mint answered for some of the outputs");
|
|
21212
|
+
return restored;
|
|
21213
|
+
}
|
|
21214
|
+
/**
|
|
21215
|
+
* The mint's signatures for these exact outputs (NUT-09), as the proofs they stand for: all of them, `none`, or
|
|
21216
|
+
* `some` (an answer that does not cover every output). It only reads what the mint already signed: nothing is
|
|
21217
|
+
* swapped again.
|
|
21218
|
+
*/
|
|
21219
|
+
async restoreOutputs(wallet, outputs) {
|
|
21220
|
+
const recovered = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
|
|
21221
|
+
if (recovered.outputs.length === 0 && recovered.signatures.length === 0) return "none";
|
|
21222
|
+
if (recovered.outputs.length !== outputs.length || recovered.signatures.length !== outputs.length) return "some";
|
|
21223
|
+
const keys = (await wallet.mint.getKeys()).keysets;
|
|
21224
|
+
const restored = [];
|
|
21225
|
+
for (const output of outputs) {
|
|
21226
|
+
const index = recovered.outputs.findIndex((o) => o.B_ === output.blindedMessage.B_);
|
|
20375
21227
|
const signature = recovered.signatures[index];
|
|
20376
21228
|
const key = signature && keys.find((k) => k.id === signature.id);
|
|
20377
21229
|
if (!signature || !key || signature.id !== output.blindedMessage.id || Amount.from(signature.amount).toString() !== Amount.from(output.blindedMessage.amount).toString()) throw new Error("Mint restore does not match reviewed outputs");
|
|
20378
21230
|
restored.push(output.toProof(signature, key));
|
|
20379
21231
|
}
|
|
20380
|
-
return
|
|
21232
|
+
return restored;
|
|
20381
21233
|
}
|
|
20382
21234
|
async finishReviewedCashu(review, prepared, keep, send) {
|
|
20383
21235
|
const token = getEncodedToken({
|
|
@@ -20434,6 +21286,72 @@ var CashuWallet = class {
|
|
|
20434
21286
|
const restored = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
|
|
20435
21287
|
return restored.signatures.length === 0 && restored.outputs.length === 0;
|
|
20436
21288
|
}
|
|
21289
|
+
/**
|
|
21290
|
+
* Whether a reviewed swap can never happen because its ecash was spent by something else: the mint answers that it
|
|
21291
|
+
* signed none of its outputs (a successful NUT-09 answer, asked before and after), and reads an input spent, none
|
|
21292
|
+
* pending. Read-only. A mint that does not restore cannot say, and the answer is no.
|
|
21293
|
+
*/
|
|
21294
|
+
async reviewedCashuSpentElsewhere(prepared) {
|
|
21295
|
+
const preview = deserializeSwapPreview(prepared.swap);
|
|
21296
|
+
if (!preview.inputs.length) return false;
|
|
21297
|
+
const wallet = await this.wallet(prepared.mint);
|
|
21298
|
+
const outputs = [...prepared.swap.keepOutputs ?? [], ...prepared.swap.sendOutputs ?? []].map(OutputData.deserialize);
|
|
21299
|
+
if (await this.signedOutputs(wallet, outputs) !== "none") return false;
|
|
21300
|
+
const states = await wallet.checkProofsStates(preview.inputs.map((p) => ({
|
|
21301
|
+
secret: p.secret,
|
|
21302
|
+
id: p.id
|
|
21303
|
+
})));
|
|
21304
|
+
if (states.length !== preview.inputs.length || states.some((s) => s.state === "PENDING") || !states.some((s) => s.state === "SPENT")) return false;
|
|
21305
|
+
return await this.signedOutputs(wallet, outputs) === "none";
|
|
21306
|
+
}
|
|
21307
|
+
/**
|
|
21308
|
+
* Ends a reviewed swap whose ecash was spent elsewhere: the inputs the mint reads spent leave the wallet, the others
|
|
21309
|
+
* are free again, and the rest of the wallet's ecash at that mint is checked the same way. Idempotent.
|
|
21310
|
+
*/
|
|
21311
|
+
async dropReviewedCashu(prepared) {
|
|
21312
|
+
const preview = deserializeSwapPreview(prepared.swap);
|
|
21313
|
+
await this.locked(prepared.mint, async () => {
|
|
21314
|
+
const states = await (await this.wallet(prepared.mint)).checkProofsStates(preview.inputs.map((p) => ({
|
|
21315
|
+
secret: p.secret,
|
|
21316
|
+
id: p.id
|
|
21317
|
+
})));
|
|
21318
|
+
if (states.length !== preview.inputs.length) throw new Error("The mint answered for another number of proofs");
|
|
21319
|
+
const spent = new Set(preview.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
21320
|
+
await transact([STORES.proofs], (stores) => {
|
|
21321
|
+
const proofs = stores[STORES.proofs];
|
|
21322
|
+
for (const input of preview.inputs) {
|
|
21323
|
+
const request = proofs.get(input.secret);
|
|
21324
|
+
request.onsuccess = () => {
|
|
21325
|
+
const proof = request.result;
|
|
21326
|
+
if (!proof || !proof.reserved || proof.mint !== prepared.mint || proof.C !== input.C) return;
|
|
21327
|
+
if (spent.has(input.secret)) {
|
|
21328
|
+
proofs.delete(input.secret);
|
|
21329
|
+
return;
|
|
21330
|
+
}
|
|
21331
|
+
const { reserved: _released, ...free } = proof;
|
|
21332
|
+
proofs.put(free);
|
|
21333
|
+
};
|
|
21334
|
+
}
|
|
21335
|
+
});
|
|
21336
|
+
});
|
|
21337
|
+
await this.recheck(prepared.mint);
|
|
21338
|
+
this.events.onChange();
|
|
21339
|
+
}
|
|
21340
|
+
/**
|
|
21341
|
+
* A proof of this wallet read spent when nothing here spent it: its ecash is in use somewhere else too. Every other
|
|
21342
|
+
* free proof at that mint is asked about once (`checkRestored`), so the balance becomes what the mint still holds
|
|
21343
|
+
* instead of one payment after another finding out.
|
|
21344
|
+
*/
|
|
21345
|
+
async recheck(mint) {
|
|
21346
|
+
const free = (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved && !p.unchecked);
|
|
21347
|
+
if (free.length > 0) await transact([STORES.proofs], (stores) => {
|
|
21348
|
+
for (const p of free) stores[STORES.proofs].put({
|
|
21349
|
+
...p,
|
|
21350
|
+
unchecked: true
|
|
21351
|
+
});
|
|
21352
|
+
});
|
|
21353
|
+
await this.checkRestored();
|
|
21354
|
+
}
|
|
20437
21355
|
/** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
|
|
20438
21356
|
async releaseReviewedCashu(prepared) {
|
|
20439
21357
|
const preview = deserializeSwapPreview(prepared.swap);
|
|
@@ -20453,6 +21371,13 @@ var CashuWallet = class {
|
|
|
20453
21371
|
});
|
|
20454
21372
|
this.events.onChange();
|
|
20455
21373
|
}
|
|
21374
|
+
/**
|
|
21375
|
+
* Whether the ecash of a reviewed payment came back to this wallet (Take it back, or a refusal). Its proofs then read
|
|
21376
|
+
* SPENT at the mint because this wallet redeemed them, not the contact.
|
|
21377
|
+
*/
|
|
21378
|
+
async reviewedCashuTakenBack(id) {
|
|
21379
|
+
return (await wrap((await store(STORES.payments, "readonly")).get(id)))?.state === "reclaimed";
|
|
21380
|
+
}
|
|
20456
21381
|
async reviewedCashuSpent(prepared) {
|
|
20457
21382
|
const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
|
|
20458
21383
|
const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
|
|
@@ -20508,18 +21433,40 @@ var CashuWallet = class {
|
|
|
20508
21433
|
if (addTestMint && !this.getKnownMints().includes(mint) && isTestMint(mint)) await this.events.onTestMintNeeded(mint);
|
|
20509
21434
|
if (!this.getKnownMints().includes(mint)) throw new Error(`Ecash from ${new URL(mint).hostname} is not accepted`);
|
|
20510
21435
|
return this.locked(mint, async () => {
|
|
20511
|
-
const
|
|
20512
|
-
|
|
20513
|
-
|
|
20514
|
-
await transact([
|
|
20515
|
-
STORES.proofs,
|
|
20516
|
-
STORES.walletTx,
|
|
20517
|
-
...payment ? [STORES.payments] : []
|
|
20518
|
-
], (stores) => {
|
|
20519
|
-
for (const p of proofs) stores[STORES.proofs].put(toStored(mint, p));
|
|
20520
|
-
stores[STORES.walletTx].put(tx);
|
|
20521
|
-
if (payment) stores[STORES.payments].put(payment(credited, mint));
|
|
21436
|
+
const records = (credited) => ({
|
|
21437
|
+
tx: walletTx(mint, kind, credited, Math.max(0, faceValue - credited), note),
|
|
21438
|
+
...payment ? { payment: payment(credited, mint) } : {}
|
|
20522
21439
|
});
|
|
21440
|
+
let swap = (await this.swapsAt(mint)).find((s) => s.kind === "receive" && s.token === token);
|
|
21441
|
+
if (swap?.finished) return {
|
|
21442
|
+
amount: swap.tx?.amount ?? 0,
|
|
21443
|
+
mint
|
|
21444
|
+
};
|
|
21445
|
+
if (!swap) {
|
|
21446
|
+
const preview = await (await this.wallet(mint)).prepareSwapToReceive(token);
|
|
21447
|
+
swap = {
|
|
21448
|
+
id: swapId(),
|
|
21449
|
+
mint,
|
|
21450
|
+
kind: "receive",
|
|
21451
|
+
swap: serializeSwapPreview(preview),
|
|
21452
|
+
createdAt: Date.now(),
|
|
21453
|
+
token,
|
|
21454
|
+
...records(preview.amount.toNumber())
|
|
21455
|
+
};
|
|
21456
|
+
await wrap((await store(STORES.swaps, "readwrite")).put(swap));
|
|
21457
|
+
}
|
|
21458
|
+
const { keep } = await this.completeSwap(swap);
|
|
21459
|
+
const credited = sats(keep);
|
|
21460
|
+
if (swap.tx && credited !== swap.tx.amount) swap = {
|
|
21461
|
+
...swap,
|
|
21462
|
+
...records(credited),
|
|
21463
|
+
tx: {
|
|
21464
|
+
...records(credited).tx,
|
|
21465
|
+
id: swap.tx.id,
|
|
21466
|
+
timestamp: swap.tx.timestamp
|
|
21467
|
+
}
|
|
21468
|
+
};
|
|
21469
|
+
await this.finishSwap(swap, keep, [], false);
|
|
20523
21470
|
this.events.onChange();
|
|
20524
21471
|
return {
|
|
20525
21472
|
amount: credited,
|
|
@@ -20527,6 +21474,334 @@ var CashuWallet = class {
|
|
|
20527
21474
|
};
|
|
20528
21475
|
});
|
|
20529
21476
|
}
|
|
21477
|
+
/**
|
|
21478
|
+
* Splits `amount`, with the fee its receiver will pay to redeem it, out of `proofs`. Coins that add up exactly need
|
|
21479
|
+
* no mint. Otherwise it is a swap: written down, with its inputs reserved, before the mint is asked. The caller
|
|
21480
|
+
* stores the result in one transaction: `inputs` out, `keep` in, `send` where it goes, and `swap` deleted.
|
|
21481
|
+
* Runs under the mint's lock.
|
|
21482
|
+
*/
|
|
21483
|
+
async split(mint, amount, proofs) {
|
|
21484
|
+
const wallet = await this.wallet(mint);
|
|
21485
|
+
const among = (chosen) => {
|
|
21486
|
+
const secrets = new Set(chosen.map((p) => p.secret));
|
|
21487
|
+
return proofs.filter((p) => secrets.has(p.secret));
|
|
21488
|
+
};
|
|
21489
|
+
try {
|
|
21490
|
+
const { send } = wallet.sendOffline(amount, asProofLike(proofs), {
|
|
21491
|
+
includeFees: true,
|
|
21492
|
+
exactMatch: true
|
|
21493
|
+
});
|
|
21494
|
+
if (send.length > 0 && sats(send) === amount + wallet.getFeesForProofs(send).toNumber()) return {
|
|
21495
|
+
inputs: among(send),
|
|
21496
|
+
keep: [],
|
|
21497
|
+
send
|
|
21498
|
+
};
|
|
21499
|
+
} catch {}
|
|
21500
|
+
const preview = await wallet.prepareSwapToSend(amount, asProofLike(proofs), { includeFees: true });
|
|
21501
|
+
const swap = {
|
|
21502
|
+
id: swapId(),
|
|
21503
|
+
mint,
|
|
21504
|
+
kind: "send",
|
|
21505
|
+
swap: serializeSwapPreview(preview),
|
|
21506
|
+
createdAt: Date.now()
|
|
21507
|
+
};
|
|
21508
|
+
const inputs = among(preview.inputs);
|
|
21509
|
+
await transact([STORES.proofs, STORES.swaps], (stores) => {
|
|
21510
|
+
for (const p of inputs) stores[STORES.proofs].put({
|
|
21511
|
+
...p,
|
|
21512
|
+
reserved: true
|
|
21513
|
+
});
|
|
21514
|
+
stores[STORES.swaps].put(swap);
|
|
21515
|
+
});
|
|
21516
|
+
const { keep, send } = await this.completeSwap(swap);
|
|
21517
|
+
return {
|
|
21518
|
+
inputs,
|
|
21519
|
+
keep,
|
|
21520
|
+
send,
|
|
21521
|
+
swap
|
|
21522
|
+
};
|
|
21523
|
+
}
|
|
21524
|
+
/**
|
|
21525
|
+
* Sends a written-down swap to the mint: always the saved one, so a second try asks for the same outputs. When the
|
|
21526
|
+
* answer is missed the mint is asked what it did, and its word decides: the outputs it signed are the result, as the
|
|
21527
|
+
* answer would have been; a swap it says it never made is undone and the failure is thrown; and for as long as the
|
|
21528
|
+
* swap stays written down nothing is known to have failed, whatever the mint's words were (`SwapUnsettledError`):
|
|
21529
|
+
* `pollSwaps` settles it. Runs under the mint's lock.
|
|
21530
|
+
*/
|
|
21531
|
+
async completeSwap(swap) {
|
|
21532
|
+
try {
|
|
21533
|
+
return await (await this.wallet(swap.mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
|
|
21534
|
+
} catch (error) {
|
|
21535
|
+
const refused = isMintOperationError(error) && swap.attemptEndedAt === void 0;
|
|
21536
|
+
const ended = {
|
|
21537
|
+
...swap,
|
|
21538
|
+
attemptEndedAt: Date.now()
|
|
21539
|
+
};
|
|
21540
|
+
await (async () => wrap((await store(STORES.swaps, "readwrite")).put(ended)))().catch(() => {});
|
|
21541
|
+
const outcome = await this.swapOutcome(ended, refused).catch(() => ({ state: "open" }));
|
|
21542
|
+
if (outcome.state === "done") return outcome;
|
|
21543
|
+
if (outcome.state === "never" && outcome.final) {
|
|
21544
|
+
await this.dropSwap(ended, outcome.gone);
|
|
21545
|
+
throw outcome.gone.size > 0 ? Object.assign(engineError("ecashAlreadySpent"), { cause: error }) : error;
|
|
21546
|
+
}
|
|
21547
|
+
this.swapsAgainIn(SWAP_POLL_MS);
|
|
21548
|
+
this.events.onChange();
|
|
21549
|
+
throw new SwapUnsettledError(ended, error);
|
|
21550
|
+
}
|
|
21551
|
+
}
|
|
21552
|
+
/**
|
|
21553
|
+
* Asks the mint where a written-down swap stands. It only asks: nothing is sent again, nothing is stored.
|
|
21554
|
+
* `refused`: the mint refused the only request ever made for this swap, so no request of it is still on its way.
|
|
21555
|
+
* Throws when the mint cannot be asked, or answers with an error: only its answers count.
|
|
21556
|
+
*/
|
|
21557
|
+
async swapOutcome(swap, refused = false) {
|
|
21558
|
+
const wallet = await this.wallet(swap.mint);
|
|
21559
|
+
const saved = swapOf(swap);
|
|
21560
|
+
const keep = (saved.keepOutputs ?? []).map(OutputData.deserialize);
|
|
21561
|
+
const send = (saved.sendOutputs ?? []).map(OutputData.deserialize);
|
|
21562
|
+
const signed = async () => {
|
|
21563
|
+
const proofs = await this.signedOutputs(wallet, [...keep, ...send]);
|
|
21564
|
+
return Array.isArray(proofs) ? {
|
|
21565
|
+
state: "done",
|
|
21566
|
+
keep: proofs.slice(0, keep.length),
|
|
21567
|
+
send: proofs.slice(keep.length)
|
|
21568
|
+
} : proofs;
|
|
21569
|
+
};
|
|
21570
|
+
const first = await signed();
|
|
21571
|
+
if (first && first !== "none") return first;
|
|
21572
|
+
const states = await wallet.checkProofsStates(saved.inputs.map((p) => ({
|
|
21573
|
+
secret: p.secret,
|
|
21574
|
+
id: p.id
|
|
21575
|
+
})));
|
|
21576
|
+
if (states.length !== saved.inputs.length) throw new Error("The mint answered for another number of proofs");
|
|
21577
|
+
if (states.some((s) => s.state === "PENDING")) return { state: "open" };
|
|
21578
|
+
const gone = new Set(saved.inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
21579
|
+
if (gone.size > 0) {
|
|
21580
|
+
const second = await signed();
|
|
21581
|
+
if (second && second !== "none") return second;
|
|
21582
|
+
if (second === "none" || refused) return {
|
|
21583
|
+
state: "never",
|
|
21584
|
+
gone,
|
|
21585
|
+
final: true
|
|
21586
|
+
};
|
|
21587
|
+
return {
|
|
21588
|
+
state: "open",
|
|
21589
|
+
slow: true
|
|
21590
|
+
};
|
|
21591
|
+
}
|
|
21592
|
+
if (refused) return {
|
|
21593
|
+
state: "never",
|
|
21594
|
+
gone,
|
|
21595
|
+
final: true
|
|
21596
|
+
};
|
|
21597
|
+
if (Date.now() >= swapOverdueAt(swap)) return {
|
|
21598
|
+
state: "never",
|
|
21599
|
+
gone,
|
|
21600
|
+
final: false
|
|
21601
|
+
};
|
|
21602
|
+
return {
|
|
21603
|
+
state: "open",
|
|
21604
|
+
idle: !!first
|
|
21605
|
+
};
|
|
21606
|
+
}
|
|
21607
|
+
/**
|
|
21608
|
+
* Stores what a swap brought, in one transaction that reads the swap first: a swap still written down was never
|
|
21609
|
+
* stored, and one stored is never stored again, whoever else works on this database. A redeem brings its ecash with
|
|
21610
|
+
* its history line and its payment record. A split finished here was not finished by its caller (the payment it was
|
|
21611
|
+
* for is over): everything it made is the wallet's. `late`: after the call that made it was over; a swap with a
|
|
21612
|
+
* payment then stays, marked `finished`, until the chat has been told (`tellSwap`). Returns whether it stored.
|
|
21613
|
+
*/
|
|
21614
|
+
async finishSwap(swap, keep, send, late) {
|
|
21615
|
+
const secrets = new Set(swap.kind === "send" ? swapOf(swap).inputs.map((p) => p.secret) : []);
|
|
21616
|
+
const inputs = (await this.allProofs()).filter((p) => p.mint === swap.mint && secrets.has(p.secret));
|
|
21617
|
+
const lost = swap.kind === "send" ? swapOf(swap).inputs.reduce((sum, p) => sum + Number(p.amount), 0) - sats(keep) - sats(send) : 0;
|
|
21618
|
+
let stored = false;
|
|
21619
|
+
await transact([
|
|
21620
|
+
STORES.proofs,
|
|
21621
|
+
STORES.swaps,
|
|
21622
|
+
STORES.walletTx,
|
|
21623
|
+
...swap.payment ? [STORES.payments] : []
|
|
21624
|
+
], (stores) => {
|
|
21625
|
+
const current = stores[STORES.swaps].get(swap.id);
|
|
21626
|
+
current.onsuccess = () => {
|
|
21627
|
+
const kept = current.result;
|
|
21628
|
+
if (!kept || kept.finished) return;
|
|
21629
|
+
stored = true;
|
|
21630
|
+
for (const p of inputs) stores[STORES.proofs].delete(p.secret);
|
|
21631
|
+
for (const p of [...keep, ...send]) stores[STORES.proofs].put({
|
|
21632
|
+
...toStored(swap.mint, p),
|
|
21633
|
+
...swap.restored ? { unchecked: true } : {}
|
|
21634
|
+
});
|
|
21635
|
+
if (swap.tx) stores[STORES.walletTx].put(swap.tx);
|
|
21636
|
+
if (lost > 0) stores[STORES.walletTx].put({
|
|
21637
|
+
...walletTx(swap.mint, "fee", 0, lost, "A payment was not sent"),
|
|
21638
|
+
id: swap.id
|
|
21639
|
+
});
|
|
21640
|
+
if (swap.payment) stores[STORES.payments].put(swap.payment);
|
|
21641
|
+
if (late && swap.payment) stores[STORES.swaps].put({
|
|
21642
|
+
...kept,
|
|
21643
|
+
finished: true
|
|
21644
|
+
});
|
|
21645
|
+
else stores[STORES.swaps].delete(swap.id);
|
|
21646
|
+
};
|
|
21647
|
+
});
|
|
21648
|
+
if (stored && swap.restored) this.checkRestored();
|
|
21649
|
+
return stored;
|
|
21650
|
+
}
|
|
21651
|
+
/**
|
|
21652
|
+
* The mint never made this swap. The inputs it held are free again, less any the mint reads spent. `watch`: it is
|
|
21653
|
+
* only overdue, so it stays written down, marked `released`, and is asked about for a while longer: should the mint
|
|
21654
|
+
* have made it after all, what it made is still recovered.
|
|
21655
|
+
*/
|
|
21656
|
+
async dropSwap(swap, gone, watch = false) {
|
|
21657
|
+
const inputs = swap.kind === "send" && !swap.released ? await this.storedInputs(swap) : [];
|
|
21658
|
+
if (gone.size > 0 && inputs.length > 0) console.warn(`[wallet] ${gone.size} proofs of a swap at ${swap.mint} that never happened are spent`);
|
|
21659
|
+
await transact([STORES.proofs, STORES.swaps], (stores) => {
|
|
21660
|
+
for (const p of inputs) if (gone.has(p.secret)) stores[STORES.proofs].delete(p.secret);
|
|
21661
|
+
else {
|
|
21662
|
+
const { reserved: _released, ...free } = p;
|
|
21663
|
+
stores[STORES.proofs].put(free);
|
|
21664
|
+
}
|
|
21665
|
+
if (watch) stores[STORES.swaps].put({
|
|
21666
|
+
...swap,
|
|
21667
|
+
released: true
|
|
21668
|
+
});
|
|
21669
|
+
else stores[STORES.swaps].delete(swap.id);
|
|
21670
|
+
});
|
|
21671
|
+
if (gone.size > 0 && inputs.length > 0) this.recheck(swap.mint).catch(() => {});
|
|
21672
|
+
}
|
|
21673
|
+
/** The proofs a split reserved, as they are stored now. */
|
|
21674
|
+
async storedInputs(swap) {
|
|
21675
|
+
const secrets = new Set(swapOf(swap).inputs.map((p) => p.secret));
|
|
21676
|
+
return (await this.allProofs()).filter((p) => p.mint === swap.mint && p.reserved && secrets.has(p.secret));
|
|
21677
|
+
}
|
|
21678
|
+
async swapsAt(mint) {
|
|
21679
|
+
return (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => s.mint === mint);
|
|
21680
|
+
}
|
|
21681
|
+
/** Says what became of a swap to whoever follows it (the chat its payment is in), and waits for it to be taken in. */
|
|
21682
|
+
async tellSwap(swap, done) {
|
|
21683
|
+
try {
|
|
21684
|
+
await this.events.onSwapSettled?.(swap, done);
|
|
21685
|
+
return true;
|
|
21686
|
+
} catch {
|
|
21687
|
+
return false;
|
|
21688
|
+
}
|
|
21689
|
+
}
|
|
21690
|
+
swapsAgainIn(ms) {
|
|
21691
|
+
if (this.swapTimer && this.swapTimerAt <= Date.now() + ms) return;
|
|
21692
|
+
if (this.swapTimer) clearTimeout(this.swapTimer);
|
|
21693
|
+
this.swapTimerAt = Date.now() + ms;
|
|
21694
|
+
this.swapTimer = setTimeout(() => void this.pollSwaps(), ms);
|
|
21695
|
+
}
|
|
21696
|
+
/**
|
|
21697
|
+
* Swaps still written down: the app closed while the mint was being asked, or the mint did not answer. Each mint is
|
|
21698
|
+
* asked where its own stand, under its lock. A swap it made is stored as its answer would have been; one it says it
|
|
21699
|
+
* never made is undone; one that is only overdue gives its inputs back and stays watched; one it cannot speak for
|
|
21700
|
+
* is asked about again. `mints`: only these mints', once, now.
|
|
21701
|
+
*/
|
|
21702
|
+
/** Asks these mints about their open swaps now (before a wallet's removal is shown or refused). */
|
|
21703
|
+
settleSwaps(mints) {
|
|
21704
|
+
return this.pollSwaps(mints);
|
|
21705
|
+
}
|
|
21706
|
+
async pollSwaps(mints) {
|
|
21707
|
+
if (!mints) {
|
|
21708
|
+
if (this.swapTimer) clearTimeout(this.swapTimer);
|
|
21709
|
+
this.swapTimer = null;
|
|
21710
|
+
}
|
|
21711
|
+
const open = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => !mints || mints.includes(s.mint));
|
|
21712
|
+
let again = 0;
|
|
21713
|
+
const later = (ms) => {
|
|
21714
|
+
again = again ? Math.min(again, ms) : ms;
|
|
21715
|
+
};
|
|
21716
|
+
for (const mint of new Set(open.map((s) => s.mint))) await this.locked(mint, async () => {
|
|
21717
|
+
for (let swap of await this.swapsAt(mint)) try {
|
|
21718
|
+
if (swap.finished) {
|
|
21719
|
+
if (await this.tellSwap(swap, true)) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21720
|
+
else later(SWAP_POLL_MS);
|
|
21721
|
+
continue;
|
|
21722
|
+
}
|
|
21723
|
+
if (swap.askAt && Date.now() < swap.askAt && !mints) {
|
|
21724
|
+
later(swap.askAt - Date.now());
|
|
21725
|
+
continue;
|
|
21726
|
+
}
|
|
21727
|
+
if (swap.attemptEndedAt === void 0) {
|
|
21728
|
+
swap = {
|
|
21729
|
+
...swap,
|
|
21730
|
+
attemptEndedAt: Date.now()
|
|
21731
|
+
};
|
|
21732
|
+
await wrap((await store(STORES.swaps, "readwrite")).put(swap));
|
|
21733
|
+
}
|
|
21734
|
+
const outcome = await this.swapOutcome(swap);
|
|
21735
|
+
if (outcome.state === "open") {
|
|
21736
|
+
if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) {
|
|
21737
|
+
await this.dropSwap(swap, /* @__PURE__ */ new Set());
|
|
21738
|
+
continue;
|
|
21739
|
+
}
|
|
21740
|
+
if (outcome.slow) {
|
|
21741
|
+
const stuck = (swap.stuck ?? 0) + 1;
|
|
21742
|
+
const wait = stuck <= 6 ? SWAP_SLOW_POLL_MS : stuck <= 30 ? 36e5 : 864e5;
|
|
21743
|
+
await wrap((await store(STORES.swaps, "readwrite")).put({
|
|
21744
|
+
...swap,
|
|
21745
|
+
stuck,
|
|
21746
|
+
askAt: Date.now() + wait
|
|
21747
|
+
}));
|
|
21748
|
+
if (stuck === 1) this.events.onChange();
|
|
21749
|
+
later(wait);
|
|
21750
|
+
} else if (outcome.idle && swap.kind === "receive" && !swap.released) try {
|
|
21751
|
+
const { keep } = await (await this.wallet(mint)).completeSwap(deserializeSwapPreview(swapOf(swap)));
|
|
21752
|
+
if (await this.finishSwap(swap, keep, [], true)) {
|
|
21753
|
+
const told = await this.tellSwap(swap, true);
|
|
21754
|
+
if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21755
|
+
else if (swap.payment) later(SWAP_POLL_MS);
|
|
21756
|
+
this.events.onChange();
|
|
21757
|
+
}
|
|
21758
|
+
} catch (error) {
|
|
21759
|
+
swap = {
|
|
21760
|
+
...swap,
|
|
21761
|
+
attemptEndedAt: Date.now()
|
|
21762
|
+
};
|
|
21763
|
+
await wrap((await store(STORES.swaps, "readwrite")).put(swap));
|
|
21764
|
+
const refusal = isMintOperationError(error) ? await this.swapOutcome(swap, true).catch(() => void 0) : void 0;
|
|
21765
|
+
if (refusal?.state === "done") {
|
|
21766
|
+
if (await this.finishSwap(swap, refusal.keep, refusal.send, true)) {
|
|
21767
|
+
const told = await this.tellSwap(swap, true);
|
|
21768
|
+
if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21769
|
+
}
|
|
21770
|
+
this.events.onChange();
|
|
21771
|
+
} else if (refusal?.state === "never") {
|
|
21772
|
+
await this.dropSwap(swap, refusal.gone);
|
|
21773
|
+
await this.tellSwap(swap, false);
|
|
21774
|
+
this.events.onChange();
|
|
21775
|
+
} else later(SWAP_POLL_MS);
|
|
21776
|
+
}
|
|
21777
|
+
else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
|
|
21778
|
+
continue;
|
|
21779
|
+
}
|
|
21780
|
+
if (outcome.state === "done") {
|
|
21781
|
+
if (await this.finishSwap(swap, outcome.keep, outcome.send, true)) {
|
|
21782
|
+
const told = await this.tellSwap(swap, true);
|
|
21783
|
+
if (swap.payment && told) await wrap((await store(STORES.swaps, "readwrite")).delete(swap.id));
|
|
21784
|
+
else if (swap.payment) later(SWAP_POLL_MS);
|
|
21785
|
+
}
|
|
21786
|
+
} else if (swap.released) {
|
|
21787
|
+
if (outcome.final || Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, outcome.gone);
|
|
21788
|
+
else later(SWAP_SLOW_POLL_MS);
|
|
21789
|
+
continue;
|
|
21790
|
+
} else {
|
|
21791
|
+
await this.dropSwap(swap, outcome.gone, !outcome.final);
|
|
21792
|
+
await this.tellSwap(swap, false);
|
|
21793
|
+
if (!outcome.final) later(SWAP_SLOW_POLL_MS);
|
|
21794
|
+
}
|
|
21795
|
+
this.events.onChange();
|
|
21796
|
+
} catch {
|
|
21797
|
+
if (swap.released && Date.now() >= swap.createdAt + SWAP_WATCH_MS) await this.dropSwap(swap, /* @__PURE__ */ new Set()).catch(() => {});
|
|
21798
|
+
else later(swap.released ? SWAP_SLOW_POLL_MS : SWAP_POLL_MS);
|
|
21799
|
+
}
|
|
21800
|
+
}).catch(() => {
|
|
21801
|
+
later(SWAP_POLL_MS);
|
|
21802
|
+
});
|
|
21803
|
+
if (again && !mints) this.swapsAgainIn(again);
|
|
21804
|
+
}
|
|
20530
21805
|
/** What paying this invoice would cost, from the first mint that can afford it. */
|
|
20531
21806
|
async quoteInvoice(invoice, network) {
|
|
20532
21807
|
let lastError = /* @__PURE__ */ new Error("Add a mint in Settings first");
|
|
@@ -20557,7 +21832,8 @@ var CashuWallet = class {
|
|
|
20557
21832
|
/**
|
|
20558
21833
|
* Pays a melt quote. True once paid; false while the mint holds the payment pending, in which case its
|
|
20559
21834
|
* proofs stay reserved and the wallet keeps asking until the mint settles it (`onMeltResolved`).
|
|
20560
|
-
* Throws only when the sats never left the wallet or are known to be back in it
|
|
21835
|
+
* Throws only when the sats never left the wallet or are known to be back in it, or when the split before the payment
|
|
21836
|
+
* has no answer yet (`SwapUnsettledError`): the payment was not made, and the split is settled by `pollSwaps`.
|
|
20561
21837
|
*/
|
|
20562
21838
|
async payQuote(quoteId, mint, note, paymentId) {
|
|
20563
21839
|
try {
|
|
@@ -20574,13 +21850,15 @@ var CashuWallet = class {
|
|
|
20574
21850
|
const quote = await wallet.checkMeltQuoteBolt11(quoteId);
|
|
20575
21851
|
if ((await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => m.quote === quote.quote || m.request === quote.request)) throw new Error("This invoice is already being paid");
|
|
20576
21852
|
const needed = quote.amount.toNumber() + quote.fee_reserve.toNumber();
|
|
20577
|
-
const
|
|
20578
|
-
const { keep, send } = await wallet.send(needed, asProofLike(proofs), { includeFees: true });
|
|
21853
|
+
const { inputs, keep, send, swap } = await this.split(mint, needed, await this.proofsAt(mint));
|
|
20579
21854
|
let preview;
|
|
20580
21855
|
try {
|
|
20581
21856
|
preview = await wallet.prepareMelt("bolt11", quote, send);
|
|
20582
21857
|
} catch (error) {
|
|
20583
|
-
await
|
|
21858
|
+
await transact([STORES.proofs, STORES.swaps], (stores) => {
|
|
21859
|
+
this.queueReplace(stores[STORES.proofs], mint, inputs, [...keep, ...send]);
|
|
21860
|
+
if (swap) stores[STORES.swaps].delete(swap.id);
|
|
21861
|
+
});
|
|
20584
21862
|
throw error;
|
|
20585
21863
|
}
|
|
20586
21864
|
const melt = {
|
|
@@ -20589,16 +21867,21 @@ var CashuWallet = class {
|
|
|
20589
21867
|
request: quote.request,
|
|
20590
21868
|
amount: quote.amount.toNumber(),
|
|
20591
21869
|
secrets: send.map((p) => p.secret),
|
|
20592
|
-
outlay: total(
|
|
21870
|
+
outlay: total(inputs) - sats(keep),
|
|
20593
21871
|
outputs: preview.outputData.map((o) => OutputData.serialize(o)),
|
|
20594
21872
|
note: note?.slice(0, 140),
|
|
20595
21873
|
paymentId,
|
|
20596
21874
|
createdAt: Date.now()
|
|
20597
21875
|
};
|
|
20598
|
-
await transact([
|
|
20599
|
-
|
|
21876
|
+
await transact([
|
|
21877
|
+
STORES.proofs,
|
|
21878
|
+
STORES.melts,
|
|
21879
|
+
STORES.swaps
|
|
21880
|
+
], (stores) => {
|
|
21881
|
+
this.queueReplace(stores[STORES.proofs], mint, inputs, keep);
|
|
20600
21882
|
for (const p of send) stores[STORES.proofs].put(toStored(mint, p, true));
|
|
20601
21883
|
stores[STORES.melts].put(melt);
|
|
21884
|
+
if (swap) stores[STORES.swaps].delete(swap.id);
|
|
20602
21885
|
});
|
|
20603
21886
|
let result;
|
|
20604
21887
|
try {
|
|
@@ -20608,6 +21891,7 @@ var CashuWallet = class {
|
|
|
20608
21891
|
const outcome = await this.settleMelt(melt).catch(() => "pending");
|
|
20609
21892
|
if (outcome === "unpaid") {
|
|
20610
21893
|
const lost = this.takeFailedFee(melt.quote);
|
|
21894
|
+
if (this.spentAway.delete(melt.quote)) throw Object.assign(engineError("ecashAlreadySpent"), { cause: error });
|
|
20611
21895
|
if (lost > 0) throw Object.assign(/* @__PURE__ */ new Error(`${error instanceof Error ? error.message : String(error)} ${backInWallet(lost)}`), { cause: error });
|
|
20612
21896
|
throw error;
|
|
20613
21897
|
}
|
|
@@ -20619,6 +21903,13 @@ var CashuWallet = class {
|
|
|
20619
21903
|
}
|
|
20620
21904
|
return this.settleMelt(melt).catch(() => "pending");
|
|
20621
21905
|
}
|
|
21906
|
+
/**
|
|
21907
|
+
* Whether a Lightning payment of this invoice is written down as in flight. A melt is written before the mint sees
|
|
21908
|
+
* its proofs, and `pollMelts` settles and reports it: an invoice with none never reached the mint's melt, or is done.
|
|
21909
|
+
*/
|
|
21910
|
+
async meltInFlight(invoice) {
|
|
21911
|
+
return (await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => m.request === invoice);
|
|
21912
|
+
}
|
|
20622
21913
|
/** Lightning payments left pending, asked about again until the mint settles each one. */
|
|
20623
21914
|
async pollMelts() {
|
|
20624
21915
|
if (this.meltTimer) clearTimeout(this.meltTimer);
|
|
@@ -20631,6 +21922,7 @@ var CashuWallet = class {
|
|
|
20631
21922
|
});
|
|
20632
21923
|
if (outcome === "paid" || outcome === "unpaid") {
|
|
20633
21924
|
this.takeFailedFee(melt.quote);
|
|
21925
|
+
this.spentAway.delete(melt.quote);
|
|
20634
21926
|
this.events.onMeltResolved(melt, outcome === "paid");
|
|
20635
21927
|
this.events.onChange();
|
|
20636
21928
|
}
|
|
@@ -20665,7 +21957,7 @@ var CashuWallet = class {
|
|
|
20665
21957
|
if (states.some((s) => s.state === "PENDING")) return "pending";
|
|
20666
21958
|
const spent = new Set(inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
20667
21959
|
if (spent.size > 0) console.warn(`[wallet] melt ${melt.quote} is unpaid, yet ${spent.size} of its proofs are spent`);
|
|
20668
|
-
const lost = melt.outlay - inputs.
|
|
21960
|
+
const lost = melt.outlay - inputs.reduce((sum, p) => sum + p.amount, 0);
|
|
20669
21961
|
await transact([
|
|
20670
21962
|
STORES.proofs,
|
|
20671
21963
|
STORES.melts,
|
|
@@ -20680,6 +21972,10 @@ var CashuWallet = class {
|
|
|
20680
21972
|
if (lost > 0) stores[STORES.walletTx].put(walletTx(melt.mint, "fee", 0, lost, melt.note ? `Payment failed: ${melt.note}` : "A Lightning payment failed"));
|
|
20681
21973
|
});
|
|
20682
21974
|
if (lost > 0) this.failedFees.set(melt.quote, lost);
|
|
21975
|
+
if (spent.size > 0) {
|
|
21976
|
+
this.spentAway.add(melt.quote);
|
|
21977
|
+
this.recheck(melt.mint).catch(() => {});
|
|
21978
|
+
}
|
|
20683
21979
|
return "unpaid";
|
|
20684
21980
|
}
|
|
20685
21981
|
/** The fee a failed melt cost, once: for the words that say it failed. */
|
|
@@ -20708,14 +22004,22 @@ var CashuWallet = class {
|
|
|
20708
22004
|
* and every invoice of theirs are deleted. A Lightning payment still in flight from one of them is not cut off: the
|
|
20709
22005
|
* removal is refused until it settles.
|
|
20710
22006
|
*/
|
|
20711
|
-
async forget(mints) {
|
|
22007
|
+
async forget(mints, acceptLoss = false) {
|
|
20712
22008
|
const at = new Set(mints);
|
|
20713
22009
|
if ((await wrap((await store(STORES.melts, "readonly")).getAll())).some((m) => at.has(m.mint))) throw new Error("A Lightning payment from this wallet is still in flight: wait for it to settle, then remove the wallet.");
|
|
22010
|
+
await this.pollSwaps(mints).catch(() => {});
|
|
22011
|
+
if ((await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => at.has(s.mint) && !s.released).length > 0 && !acceptLoss) throw new Error("An exchange of ecash with this wallet's mint is not finished yet: wait for the mint to answer, or confirm that what it would bring is lost to remove the wallet.");
|
|
20714
22012
|
const proofs = (await this.allProofs()).filter((p) => at.has(p.mint));
|
|
20715
22013
|
const quotes = (await wrap((await store(STORES.quotes, "readonly")).getAll())).filter((q) => at.has(q.mint));
|
|
20716
|
-
await
|
|
22014
|
+
const swaps = (await wrap((await store(STORES.swaps, "readonly")).getAll())).filter((s) => at.has(s.mint));
|
|
22015
|
+
await transact([
|
|
22016
|
+
STORES.proofs,
|
|
22017
|
+
STORES.quotes,
|
|
22018
|
+
STORES.swaps
|
|
22019
|
+
], (stores) => {
|
|
20717
22020
|
for (const p of proofs) stores[STORES.proofs].delete(p.secret);
|
|
20718
22021
|
for (const q of quotes) stores[STORES.quotes].delete(q.quote);
|
|
22022
|
+
for (const s of swaps) stores[STORES.swaps].delete(s.id);
|
|
20719
22023
|
});
|
|
20720
22024
|
for (const mint of mints) {
|
|
20721
22025
|
this.wallets.delete(mint);
|
|
@@ -20771,10 +22075,7 @@ var CashuWallet = class {
|
|
|
20771
22075
|
async proofsAt(mint) {
|
|
20772
22076
|
return (await this.allProofs()).filter((p) => p.mint === mint && !p.reserved);
|
|
20773
22077
|
}
|
|
20774
|
-
/** After a swap: the inputs are spent, `keep` is what came back to us.
|
|
20775
|
-
async replaceProofs(mint, inputs, keep) {
|
|
20776
|
-
await transact([STORES.proofs], (stores) => this.queueReplace(stores[STORES.proofs], mint, inputs, keep));
|
|
20777
|
-
}
|
|
22078
|
+
/** After a swap: the inputs are spent, `keep` is what came back to us. Queued in the caller's transaction: all or nothing. */
|
|
20778
22079
|
queueReplace(proofStore, mint, inputs, keep) {
|
|
20779
22080
|
const kept = new Set(keep.map((p) => p.secret));
|
|
20780
22081
|
for (const p of inputs) if (!kept.has(p.secret)) proofStore.delete(p.secret);
|
|
@@ -21456,7 +22757,11 @@ var PaymentDesk = class {
|
|
|
21456
22757
|
}
|
|
21457
22758
|
if (!payment?.token || payment.direction !== "out") throw new Error("Nothing to reclaim");
|
|
21458
22759
|
try {
|
|
21459
|
-
await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo)
|
|
22760
|
+
await this.wallet.receiveToken(payment.token, "reclaimed", payment.memo, { payment: () => ({
|
|
22761
|
+
...this.current(payment),
|
|
22762
|
+
state: "reclaimed",
|
|
22763
|
+
token: void 0
|
|
22764
|
+
}) });
|
|
21460
22765
|
await this.save({
|
|
21461
22766
|
...this.current(payment),
|
|
21462
22767
|
state: "reclaimed",
|
|
@@ -21767,31 +23072,18 @@ var PaymentDesk = class {
|
|
|
21767
23072
|
addTestMint: true,
|
|
21768
23073
|
payment: record
|
|
21769
23074
|
});
|
|
21770
|
-
await this.
|
|
21771
|
-
|
|
21772
|
-
if (
|
|
21773
|
-
|
|
21774
|
-
|
|
21775
|
-
|
|
21776
|
-
|
|
21777
|
-
|
|
23075
|
+
await this.creditCashu(record(amount, mint), link);
|
|
23076
|
+
} catch (error) {
|
|
23077
|
+
if (error instanceof SwapUnsettledError) return;
|
|
23078
|
+
const settled = this.payments.get(payment.id);
|
|
23079
|
+
if (settled && settled.linkId === linkId && settled.direction === "in" && settled.kind === "payment" && settled.state === "settled") {
|
|
23080
|
+
link?.sendPaymentResult({
|
|
23081
|
+
id: payment.id,
|
|
23082
|
+
ok: true,
|
|
23083
|
+
credited: String(settled.amount)
|
|
21778
23084
|
});
|
|
23085
|
+
return;
|
|
21779
23086
|
}
|
|
21780
|
-
await this.host.storeMessage({
|
|
21781
|
-
linkId,
|
|
21782
|
-
id: `peer_${payment.timestamp}`,
|
|
21783
|
-
text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
|
|
21784
|
-
sender: "peer",
|
|
21785
|
-
timestamp: payment.timestamp,
|
|
21786
|
-
via: "datalink",
|
|
21787
|
-
paymentId: payment.id
|
|
21788
|
-
});
|
|
21789
|
-
link?.sendPaymentResult({
|
|
21790
|
-
id: payment.id,
|
|
21791
|
-
ok: true,
|
|
21792
|
-
credited: String(amount)
|
|
21793
|
-
});
|
|
21794
|
-
} catch (error) {
|
|
21795
23087
|
const reason = error instanceof Error ? error.message : String(error);
|
|
21796
23088
|
link?.sendPaymentResult({
|
|
21797
23089
|
id: payment.id,
|
|
@@ -21811,6 +23103,61 @@ var PaymentDesk = class {
|
|
|
21811
23103
|
} catch {}
|
|
21812
23104
|
}
|
|
21813
23105
|
}
|
|
23106
|
+
/**
|
|
23107
|
+
* A contact's ecash is in the wallet, and its record was written with it in the same transaction: the request it
|
|
23108
|
+
* pays is settled, the chat shows it, and the contact is told.
|
|
23109
|
+
*/
|
|
23110
|
+
async creditCashu(record, link) {
|
|
23111
|
+
const { linkId, amount } = record;
|
|
23112
|
+
const mint = record.mint;
|
|
23113
|
+
await this.save(record);
|
|
23114
|
+
const request = record.requestId ? this.payments.get(record.requestId) : void 0;
|
|
23115
|
+
if (request?.kind === "request" && request.direction === "out" && this.owns(request, linkId) && request.state === "pending" && amount >= request.amount && (request.mints ?? []).includes(mint) && (!isWorthlessMint(mint) || (request.mints ?? []).every(isWorthlessMint))) {
|
|
23116
|
+
if (request.group) await this.settleRequest(request, { mint });
|
|
23117
|
+
else await this.save({
|
|
23118
|
+
...request,
|
|
23119
|
+
state: "settled",
|
|
23120
|
+
mint
|
|
23121
|
+
});
|
|
23122
|
+
}
|
|
23123
|
+
await this.host.storeMessage({
|
|
23124
|
+
linkId,
|
|
23125
|
+
id: `peer_${record.createdAt}`,
|
|
23126
|
+
text: `⚡ ${amount.toLocaleString()} ${isWorthlessMint(mint) ? "test sats" : "sats"}`,
|
|
23127
|
+
sender: "peer",
|
|
23128
|
+
timestamp: record.createdAt,
|
|
23129
|
+
via: "datalink",
|
|
23130
|
+
paymentId: record.id
|
|
23131
|
+
});
|
|
23132
|
+
link?.sendPaymentResult({
|
|
23133
|
+
id: record.id,
|
|
23134
|
+
ok: true,
|
|
23135
|
+
credited: String(amount)
|
|
23136
|
+
});
|
|
23137
|
+
}
|
|
23138
|
+
/**
|
|
23139
|
+
* A redeem the mint had not answered is settled, after the call that made it was over (`CashuWallet.pollSwaps`).
|
|
23140
|
+
* Done: the ecash and its payment record are already stored, and the rest follows as if the answer had come in time.
|
|
23141
|
+
* Not done: the mint never made it, so a contact's ecash is still the contact's, and it is told so.
|
|
23142
|
+
*/
|
|
23143
|
+
async onSwapSettled(swap, done) {
|
|
23144
|
+
const record = swap.payment;
|
|
23145
|
+
if (!record) return;
|
|
23146
|
+
const link = this.host.getLink(record.linkId);
|
|
23147
|
+
if (record.direction === "out") {
|
|
23148
|
+
if (done) {
|
|
23149
|
+
this.payments.set(record.id, record);
|
|
23150
|
+
this.host.onChange();
|
|
23151
|
+
}
|
|
23152
|
+
return;
|
|
23153
|
+
}
|
|
23154
|
+
if (done) await this.creditCashu(record, link);
|
|
23155
|
+
else if (!this.payments.has(record.id)) link?.sendPaymentResult({
|
|
23156
|
+
id: record.id,
|
|
23157
|
+
ok: false,
|
|
23158
|
+
error: "The mint did not confirm the ecash"
|
|
23159
|
+
});
|
|
23160
|
+
}
|
|
21814
23161
|
async onPaymentResult(linkId, result) {
|
|
21815
23162
|
const payment = this.find(linkId, result.id);
|
|
21816
23163
|
if (payment?.target?.method === "fedimint" && payment.linkId === linkId && payment.direction === "out" && payment.kind === "payment") {
|
|
@@ -23328,6 +24675,20 @@ function knockCue(before, after) {
|
|
|
23328
24675
|
}
|
|
23329
24676
|
/** A group's own line that says I made it or joined it (not someone else joining). */
|
|
23330
24677
|
var groupCue = (message) => message.event === "created" || message.event === "joined" && !message.member ? "group" : void 0;
|
|
24678
|
+
/**
|
|
24679
|
+
* When a contact's message was written, by this device's clock. The time it says is its sender's clock, and a clock
|
|
24680
|
+
* behind this one makes everything it writes look old: a reply to what I just wrote read as written minutes before
|
|
24681
|
+
* my message, a late catch-up, and nothing rang. `leads`: for the contact's latest messages on the live session, how
|
|
24682
|
+
* far the time each said was from when it came (`sentAt - timestamp`). A message cannot come before it is written, so
|
|
24683
|
+
* the one that came quickest says how far behind the contact's clock is, and every time it says is read that much
|
|
24684
|
+
* later. A clock ahead needs nothing: a time still to come is taken as when the message came (as `shownTime` does).
|
|
24685
|
+
* With no live message to tell the clock by, the time is taken as said.
|
|
24686
|
+
*/
|
|
24687
|
+
function writtenAt(message, leads) {
|
|
24688
|
+
if (message.sentAt === void 0) return message.timestamp;
|
|
24689
|
+
const behind = leads?.length ? Math.min(0, Math.max(...leads)) : 0;
|
|
24690
|
+
return Math.min(message.timestamp, message.sentAt - behind);
|
|
24691
|
+
}
|
|
23331
24692
|
var JOIN_NOTICE = /^👋 (?:.+ )?joined$/;
|
|
23332
24693
|
/** What a newly stored row says, or null when it is quiet. `type`: "message" for the contact's, "sent" for mine. */
|
|
23333
24694
|
function messageAttention(type, message, { startedAt, newest, now }) {
|
|
@@ -23767,6 +25128,8 @@ var SPARE_INVITE_WARM_AFTER_TAKE_MS = 3e4;
|
|
|
23767
25128
|
*/
|
|
23768
25129
|
var STARTUP_QUIET_MS = 15e3;
|
|
23769
25130
|
var SPARE_INVITE_MAX_AGE_MS = 9e5;
|
|
25131
|
+
/** How far before this clock a warm packet is dated (`warmKey`): further than a contact's clock is ever behind. */
|
|
25132
|
+
var WARM_DATED_BACK_MS = 864e5;
|
|
23770
25133
|
/** How long a removal waits for a wallet's rail to claim what was already paid to it (tests shorten it). */
|
|
23771
25134
|
var removalTiming = { claimMs: 3e4 };
|
|
23772
25135
|
/** Why a removal needs the person's confirmation: what it holds, what it still waits for, and what confirming means. */
|
|
@@ -23843,6 +25206,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23843
25206
|
directPath = new DirectPathWatch(() => {
|
|
23844
25207
|
if (!this.shuttingDown) this.emitState();
|
|
23845
25208
|
});
|
|
25209
|
+
/** Whether this device's clock seems to be off, from what the relays' answers and several contacts' packets say (`clockWatch.ts`). */
|
|
25210
|
+
clock = new ClockWatch(() => {
|
|
25211
|
+
if (!this.shuttingDown) this.emitState();
|
|
25212
|
+
});
|
|
25213
|
+
clockOff;
|
|
23846
25214
|
/**
|
|
23847
25215
|
* Edges of groups whose WebRTC did not connect in this run of the app (`edgeWithoutRtc`): they go on as an app with
|
|
23848
25216
|
* no WebRTC does, over a native transport. Not kept: the next start tries WebRTC first again.
|
|
@@ -23891,11 +25259,21 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23891
25259
|
newestAt = /* @__PURE__ */ new Map();
|
|
23892
25260
|
/** The last place given in each chat while this engine runs (`arrivalKey`). */
|
|
23893
25261
|
placedAt = /* @__PURE__ */ new Map();
|
|
25262
|
+
/**
|
|
25263
|
+
* Each 1:1 chat's contact: how far the time its latest live messages said was from when they came (`CLOCK_SAMPLES`
|
|
25264
|
+
* of them). What tells a contact's clock that is behind from a message that is late (`writtenAt`).
|
|
25265
|
+
*/
|
|
25266
|
+
clockLeads = /* @__PURE__ */ new Map();
|
|
25267
|
+
/** A contact's row came on the live session of a 1:1 chat: what it says of the contact's clock is kept. */
|
|
25268
|
+
clockSample(message) {
|
|
25269
|
+
if (message.sender !== "peer" || message.sentAt === void 0 || message.event || message.via !== "datalink" || message.linkId.startsWith("group:")) return;
|
|
25270
|
+
this.clockLeads.set(message.linkId, [...this.clockLeads.get(message.linkId) ?? [], message.sentAt - message.timestamp].slice(-5));
|
|
25271
|
+
}
|
|
23894
25272
|
/** Only a new message, or mine going out, says anything (engine/attention.ts): judged by when it was written. */
|
|
23895
25273
|
messageFeedback(type, message, newest = this.newestAt.get(message.linkId)) {
|
|
23896
25274
|
const attention = messageAttention(type, {
|
|
23897
25275
|
...message,
|
|
23898
|
-
timestamp:
|
|
25276
|
+
timestamp: writtenAt(message, this.clockLeads.get(message.linkId))
|
|
23899
25277
|
}, {
|
|
23900
25278
|
startedAt: this.feedbackStartedAt,
|
|
23901
25279
|
newest,
|
|
@@ -23947,6 +25325,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23947
25325
|
deleted: (linkId, messageId) => !!this.links.get(linkId)?.stored.deletedIds?.includes(messageId),
|
|
23948
25326
|
messageExists: (linkId, messageId) => db.hasMessage(linkId, messageId),
|
|
23949
25327
|
storeMessage: (message) => this.storeMessage(message),
|
|
25328
|
+
place: (linkId) => {
|
|
25329
|
+
const place = arrivalKey(Date.now(), this.placedAt.get(linkId));
|
|
25330
|
+
this.placedAt.set(linkId, place);
|
|
25331
|
+
return place;
|
|
25332
|
+
},
|
|
23950
25333
|
transfers: this.transfers,
|
|
23951
25334
|
changed: (delayMs) => this.emitState(delayMs),
|
|
23952
25335
|
flush: () => this.flushState(),
|
|
@@ -24113,7 +25496,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24113
25496
|
paymentId: melt.paymentId,
|
|
24114
25497
|
mint: melt.mint
|
|
24115
25498
|
}),
|
|
24116
|
-
onTestMintNeeded: async (mint) => void await this.walletAddMint({ url: mint })
|
|
25499
|
+
onTestMintNeeded: async (mint) => void await this.walletAddMint({ url: mint }),
|
|
25500
|
+
onSwapSettled: (swap, done) => this.desk.onSwapSettled(swap, done).then(() => swap.payment?.direction === "out" ? this.reviewTakenBack(swap.payment.id) : void 0)
|
|
24117
25501
|
}, () => this.settings.mints);
|
|
24118
25502
|
registry;
|
|
24119
25503
|
providers() {
|
|
@@ -24168,7 +25552,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24168
25552
|
const stored = this.links.get(linkId)?.stored;
|
|
24169
25553
|
return stored?.group && !stored.groupEntry ? stored.group : parsePayLink(linkId)?.groupId;
|
|
24170
25554
|
},
|
|
24171
|
-
groupLinks: (groupId) => this.groups.isCommunityGroup(groupId) ? [groupLinkId(groupId)] : [...this.
|
|
25555
|
+
groupLinks: (groupId) => this.groups.isCommunityGroup(groupId) ? [groupLinkId(groupId)] : [...this.memberEdges(groupId).values()],
|
|
24172
25556
|
storeMessage: (message) => this.storeMessage(message),
|
|
24173
25557
|
heldPaymentMethods: (linkId) => {
|
|
24174
25558
|
const live = this.links.get(linkId);
|
|
@@ -24594,11 +25978,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24594
25978
|
/** Private groups (WISP 900): sessions, admission on contact chats, and the pairwise edges that carry them. */
|
|
24595
25979
|
groups = new Groups({
|
|
24596
25980
|
sendOnLink: (linkId, frame) => {
|
|
24597
|
-
const
|
|
24598
|
-
if (!link) throw new Error("You are offline");
|
|
24599
|
-
|
|
25981
|
+
const live = this.links.get(linkId);
|
|
25982
|
+
if (!live?.link) throw new Error("You are offline");
|
|
25983
|
+
const { group, groupPeer, groupEntry } = live.stored;
|
|
25984
|
+
if (group && groupPeer && !groupEntry && !this.groups.edgeAllows(group, groupPeer, frame)) return;
|
|
25985
|
+
live.link.sendGroupFrame(frame);
|
|
24600
25986
|
},
|
|
24601
25987
|
linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
|
|
25988
|
+
linkOpen: (linkId) => !!this.links.get(linkId)?.link?.isDataLinkOpen,
|
|
24602
25989
|
myNick: () => this.sharedNick,
|
|
24603
25990
|
staysOnline: () => this.options.staysOnline ?? (this.options.platform === "desktop" && typeof RTCPeerConnection !== "undefined"),
|
|
24604
25991
|
peerRoom: (groupId) => {
|
|
@@ -24630,7 +26017,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24630
26017
|
},
|
|
24631
26018
|
linkBack: (linkId) => {
|
|
24632
26019
|
const live = this.links.get(linkId);
|
|
24633
|
-
return !!live?.lastSyncAt && !!live.presence?.online && live.presence
|
|
26020
|
+
return !!live?.lastSyncAt && !!live.presence?.online && presenceSeenAt(live.presence) > live.lastSyncAt;
|
|
24634
26021
|
},
|
|
24635
26022
|
publish: (identity, records, background) => this.groupTransport.publish(identity, records, { background }),
|
|
24636
26023
|
resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
|
|
@@ -24717,7 +26104,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24717
26104
|
}
|
|
24718
26105
|
/** The link payments with `linkId` go over: a chat's or an edge's, or a community member's through the group. */
|
|
24719
26106
|
paymentLink(linkId) {
|
|
24720
|
-
return this.links.get(linkId)?.link ?? this.communityPay.link(linkId);
|
|
26107
|
+
return this.outsideEdge(linkId) ? null : this.links.get(linkId)?.link ?? this.communityPay.link(linkId);
|
|
24721
26108
|
}
|
|
24722
26109
|
/**
|
|
24723
26110
|
* Payments in groups (WISP 9xx § Payments): the money goes over the edge to one member through the desk, like a
|
|
@@ -24736,7 +26123,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24736
26123
|
member: stored.groupPeer
|
|
24737
26124
|
} : void 0;
|
|
24738
26125
|
},
|
|
24739
|
-
edges: (groupId) => this.groups.isCommunityGroup(groupId) ? /* @__PURE__ */ new Map([["*", groupLinkId(groupId)]]) : this.
|
|
26126
|
+
edges: (groupId) => this.groups.isCommunityGroup(groupId) ? /* @__PURE__ */ new Map([["*", groupLinkId(groupId)]]) : this.memberEdges(groupId),
|
|
24740
26127
|
membership: (groupId) => this.membership(groupId),
|
|
24741
26128
|
send: (linkId, frame) => {
|
|
24742
26129
|
const pay = parsePayLink(linkId);
|
|
@@ -24760,12 +26147,46 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24760
26147
|
this.emitState();
|
|
24761
26148
|
}
|
|
24762
26149
|
});
|
|
24763
|
-
/**
|
|
26150
|
+
/**
|
|
26151
|
+
* Member key → edge link id, for the edges of a group that exist (open or not), whoever is at the other end: an edge
|
|
26152
|
+
* outlives its member's place in the roster for a moment, or for as long as it is kept to tell it (`StoredGroup.farewells`).
|
|
26153
|
+
* For the group engine alone, which opens and closes them. Whatever is said to the group goes over `memberEdges`.
|
|
26154
|
+
*/
|
|
24764
26155
|
groupEdges(groupId) {
|
|
24765
26156
|
const edges = /* @__PURE__ */ new Map();
|
|
24766
26157
|
for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer && !live.stored.groupEntry) edges.set(live.stored.groupPeer, live.stored.id);
|
|
24767
26158
|
return edges;
|
|
24768
26159
|
}
|
|
26160
|
+
/** The edges of a private group to its members as the roster has them now: the only ones the group's traffic goes over. */
|
|
26161
|
+
memberEdges(groupId) {
|
|
26162
|
+
const edges = this.groupEdges(groupId);
|
|
26163
|
+
for (const key of [...edges.keys()]) if (!this.groups.inRoster(groupId, key)) edges.delete(key);
|
|
26164
|
+
return edges;
|
|
26165
|
+
}
|
|
26166
|
+
/** An edge of a private group to someone its roster no longer has: it says no name and no picture of mine. */
|
|
26167
|
+
quietEdge(stored) {
|
|
26168
|
+
return !!stored && this.meshEdge(stored) && this.groups.outsider(stored.group, stored.groupPeer);
|
|
26169
|
+
}
|
|
26170
|
+
/** Edges that said no name while their other end was out of the roster: the name is said again once it is back in. */
|
|
26171
|
+
quietEdges = /* @__PURE__ */ new Set();
|
|
26172
|
+
/** A group's roster changed: an edge kept to someone now out of it (closed, waiting for them) stops carrying my name. */
|
|
26173
|
+
quietOutsideEdges(groupId) {
|
|
26174
|
+
for (const live of this.links.values()) {
|
|
26175
|
+
if (live.stored.group !== groupId || !live.link || !this.meshEdge(live.stored)) continue;
|
|
26176
|
+
const id = live.stored.id;
|
|
26177
|
+
if (this.quietEdge(live.stored)) {
|
|
26178
|
+
if (!this.quietEdges.has(id) && !live.link.isDataLinkOpen) {
|
|
26179
|
+
this.quietEdges.add(id);
|
|
26180
|
+
live.link.setNick(void 0);
|
|
26181
|
+
}
|
|
26182
|
+
} else if (this.quietEdges.delete(id)) live.link.setNick(this.sharedNick);
|
|
26183
|
+
}
|
|
26184
|
+
}
|
|
26185
|
+
/** An edge of a private group whose other end is not in its roster: nothing of the group goes over it but what the group engine says. */
|
|
26186
|
+
outsideEdge(linkId) {
|
|
26187
|
+
const stored = this.links.get(linkId)?.stored;
|
|
26188
|
+
return !!stored && this.meshEdge(stored) && !this.groups.inRoster(stored.group, stored.groupPeer);
|
|
26189
|
+
}
|
|
24769
26190
|
constructor(events, options = {}) {
|
|
24770
26191
|
this.events = events;
|
|
24771
26192
|
this.options = options;
|
|
@@ -24782,6 +26203,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24782
26203
|
this.localFetch = options.localFetch ?? webLocalFetch;
|
|
24783
26204
|
this.hold.host.transport = this.transport;
|
|
24784
26205
|
this.transport.subscribe?.(() => this.emitState());
|
|
26206
|
+
this.clockOff = this.transport.onServerTime?.((time) => this.clock.server(time.source, time.date, time.sent, time.received));
|
|
24785
26207
|
}
|
|
24786
26208
|
/** The Desktop reaches the DHT itself: the relays in Settings are written to, and read from only when the person chose so. */
|
|
24787
26209
|
configureDirect() {
|
|
@@ -24865,6 +26287,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24865
26287
|
networks[network] = {
|
|
24866
26288
|
mints: this.withWaits(view.mints, awaiting.mints),
|
|
24867
26289
|
balance: view.balance,
|
|
26290
|
+
setAside: view.setAside,
|
|
26291
|
+
openSwaps: view.openSwaps,
|
|
26292
|
+
swapsAmount: view.swapsAmount,
|
|
26293
|
+
unconfirmed: view.unconfirmed,
|
|
24868
26294
|
history,
|
|
24869
26295
|
feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0),
|
|
24870
26296
|
ark: this.arkWallets[network].view,
|
|
@@ -25018,6 +26444,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25018
26444
|
const files = linkFilesFrom(stored.id, storedFiles, messages);
|
|
25019
26445
|
this.links.set(stored.id, newLiveLink(stored, messages[messages.length - 1]?.timestamp ?? 0, files));
|
|
25020
26446
|
history.set(stored.id, messages);
|
|
26447
|
+
for (const message of messages.slice(-20)) this.clockSample(message);
|
|
25021
26448
|
const note = latestReaction(messages);
|
|
25022
26449
|
if (note) this.reactionNotes.set(stored.id, note);
|
|
25023
26450
|
if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
|
|
@@ -25087,6 +26514,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25087
26514
|
this.shuttingDown = true;
|
|
25088
26515
|
this.depart();
|
|
25089
26516
|
this.directPath.close();
|
|
26517
|
+
this.clock.close();
|
|
26518
|
+
this.clockOff?.();
|
|
25090
26519
|
if (this.relayRetry) clearTimeout(this.relayRetry);
|
|
25091
26520
|
if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
|
|
25092
26521
|
for (const timer of this.resumeTimers.values()) clearTimeout(timer);
|
|
@@ -25156,6 +26585,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25156
26585
|
...this.transport.discovery && { discovery: this.transport.discovery() },
|
|
25157
26586
|
...typeof RTCPeerConnection === "undefined" && { webrtc: false },
|
|
25158
26587
|
...this.directPath.blocked && { directBlocked: true },
|
|
26588
|
+
...this.clock.offset !== null && { clockOffMs: this.clock.offset },
|
|
25159
26589
|
...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
|
|
25160
26590
|
},
|
|
25161
26591
|
links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
|
|
@@ -25236,6 +26666,22 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25236
26666
|
before: from
|
|
25237
26667
|
});
|
|
25238
26668
|
}
|
|
26669
|
+
async statusCardIndex() {
|
|
26670
|
+
const rows = [];
|
|
26671
|
+
for (const m of await db.getCardMessages()) {
|
|
26672
|
+
if (m.card?.kind !== "task" && m.card?.kind !== "routine") continue;
|
|
26673
|
+
rows.push({
|
|
26674
|
+
linkId: m.linkId,
|
|
26675
|
+
id: m.id,
|
|
26676
|
+
card: m.card,
|
|
26677
|
+
sender: m.sender,
|
|
26678
|
+
...m.member && { member: m.member },
|
|
26679
|
+
timestamp: m.timestamp,
|
|
26680
|
+
...m.edit && { editedAt: m.edit.at }
|
|
26681
|
+
});
|
|
26682
|
+
}
|
|
26683
|
+
return rows;
|
|
26684
|
+
}
|
|
25239
26685
|
async createLink() {
|
|
25240
26686
|
const { mine, inviteCode } = this.takeInvite();
|
|
25241
26687
|
return {
|
|
@@ -25263,6 +26709,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25263
26709
|
inviteCode: spare.inviteCode
|
|
25264
26710
|
};
|
|
25265
26711
|
}
|
|
26712
|
+
/**
|
|
26713
|
+
* Warms a key with an empty packet (`emptyLinkRecords`), dated `WARM_DATED_BACK_MS` before this clock. One of the two
|
|
26714
|
+
* keys an invite warms is the contact's: its own first packet has to be the later one, or the relays and the DHT keep
|
|
26715
|
+
* this empty one in its place. Dated by this clock, it was later than the first packets of every contact whose clock
|
|
26716
|
+
* runs behind this one: a joiner two minutes behind stayed unseen for two minutes, one an hour behind for an hour.
|
|
26717
|
+
*/
|
|
26718
|
+
warmKey(identity) {
|
|
26719
|
+
const records = emptyLinkRecords();
|
|
26720
|
+
(this.transport.publishPayload ? this.transport.publishPayload(identity.pubKeyZ32, createRelayPayload(identity, records, BigInt(Date.now() - WARM_DATED_BACK_MS) * 1000n)) : this.transport.publish(identity, records)).catch(() => {});
|
|
26721
|
+
}
|
|
25266
26722
|
makeSpare() {
|
|
25267
26723
|
const { mine, invite, inviteCode } = createChatInvite();
|
|
25268
26724
|
return {
|
|
@@ -25288,7 +26744,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25288
26744
|
spare.warmedAt ??= Date.now();
|
|
25289
26745
|
for (const identity of [identityFromSeedB64(spare.mine.seedB64), spare.inviteKey]) {
|
|
25290
26746
|
this.warmedKeys.set(identity.pubKeyZ32, Date.now());
|
|
25291
|
-
this.
|
|
26747
|
+
this.warmKey(identity);
|
|
25292
26748
|
}
|
|
25293
26749
|
this.spareTimer = setTimeout(() => {
|
|
25294
26750
|
this.spareTimer = null;
|
|
@@ -25591,6 +27047,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25591
27047
|
* (a group on hubs dropped it) and is no longer in the group, or a group I left or forgot, replaces it.
|
|
25592
27048
|
*/
|
|
25593
27049
|
groupMembersChanged(groupId) {
|
|
27050
|
+
this.quietOutsideEdges(groupId);
|
|
25594
27051
|
const holders = this.settings.wakeHeldBy?.[groupId];
|
|
25595
27052
|
if (!holders?.length) return;
|
|
25596
27053
|
const members = this.membership(groupId)?.members;
|
|
@@ -25646,7 +27103,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25646
27103
|
async shareGroupWake(linkId) {
|
|
25647
27104
|
const live = this.links.get(linkId);
|
|
25648
27105
|
const own = this.settings.wake;
|
|
25649
|
-
if (!live?.link?.groupsSupport || !this.meshEdge(live.stored)) return;
|
|
27106
|
+
if (!live?.link?.groupsSupport || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
|
|
25650
27107
|
if (this.settings.wakeMutedGroups?.includes(live.stored.group)) {
|
|
25651
27108
|
this.sendGroupWake(live, null);
|
|
25652
27109
|
return;
|
|
@@ -25670,7 +27127,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25670
27127
|
/** A `group-wake` frame on an edge: the member it is pinned to shares how to wake it, or says to forget it. */
|
|
25671
27128
|
receiveGroupWake(linkId, frame) {
|
|
25672
27129
|
const live = this.links.get(linkId);
|
|
25673
|
-
if (!live || !this.meshEdge(live.stored)) return;
|
|
27130
|
+
if (!live || !this.meshEdge(live.stored) || this.outsideEdge(linkId)) return;
|
|
25674
27131
|
let window = this.groupWakeReceived.get(linkId);
|
|
25675
27132
|
if (!window) this.groupWakeReceived.set(linkId, window = new RateWindow(6, 6e4));
|
|
25676
27133
|
if (!window.take()) return;
|
|
@@ -25689,7 +27146,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25689
27146
|
*/
|
|
25690
27147
|
wakeMentioned(groupId, text, mentions) {
|
|
25691
27148
|
if (!mentions.length || !this.settings.online || this.groups.isCommunityGroup(groupId)) return;
|
|
25692
|
-
const edges = this.
|
|
27149
|
+
const edges = this.memberEdges(groupId);
|
|
25693
27150
|
const wakes = groupWakes({
|
|
25694
27151
|
group: groupId,
|
|
25695
27152
|
mentions,
|
|
@@ -26338,6 +27795,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26338
27795
|
outboxFor(linkId) {
|
|
26339
27796
|
let outbox = this.outboxes.get(linkId);
|
|
26340
27797
|
if (!outbox) {
|
|
27798
|
+
const chat = this.links.get(linkId)?.stored;
|
|
26341
27799
|
outbox = new Outbox({
|
|
26342
27800
|
read: () => db.getMessages(linkId),
|
|
26343
27801
|
update: async (id, delivery, error, extra) => {
|
|
@@ -26367,10 +27825,12 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26367
27825
|
}, { resender: {
|
|
26368
27826
|
ready: (message) => {
|
|
26369
27827
|
const live = this.links.get(linkId), link = live?.link;
|
|
27828
|
+
if (message.file && !message.wireId) return !!link?.isDataLinkOpen && link.textDelivery === "stream";
|
|
26370
27829
|
if (message.via === "pkarr" && !GhostlyNode.dhtByChoice(live) && link?.textDelivery !== "stream") return false;
|
|
26371
27830
|
return !!link?.canSendText && !!message.wireId && !link.validateText(message.text, message.timestamp, message.wireId, GhostlyNode.wireReply(message));
|
|
26372
27831
|
},
|
|
26373
27832
|
requeueExpired: () => !GhostlyNode.dhtByChoice(this.links.get(linkId)),
|
|
27833
|
+
...!!chat?.profile && !chat.group && { sendFile: (message) => this.sendWaitingFile(linkId, message) },
|
|
26374
27834
|
via: (message) => {
|
|
26375
27835
|
const delivery = this.links.get(linkId)?.link?.textDelivery;
|
|
26376
27836
|
return delivery === "dht" ? "pkarr" : delivery === "stream" ? "datalink" : message.via;
|
|
@@ -26745,12 +28205,19 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26745
28205
|
*/
|
|
26746
28206
|
transferFile(live, file, wireId, timestamp, fail, reply, forwarded) {
|
|
26747
28207
|
const { link } = live;
|
|
26748
|
-
if (!link)
|
|
28208
|
+
if (!link) {
|
|
28209
|
+
fail("You are offline");
|
|
28210
|
+
return Promise.resolve();
|
|
28211
|
+
}
|
|
26749
28212
|
this.transfers.set(file.id, {
|
|
26750
28213
|
state: "transferring",
|
|
26751
28214
|
transferred: 0,
|
|
26752
28215
|
size: file.size
|
|
26753
28216
|
});
|
|
28217
|
+
let said = () => {};
|
|
28218
|
+
const offered = new Promise((resolve) => {
|
|
28219
|
+
said = resolve;
|
|
28220
|
+
});
|
|
26754
28221
|
(async () => {
|
|
26755
28222
|
if (!reply || !forwarded) {
|
|
26756
28223
|
const message = (await db.getMessages(live.stored.id)).find((m) => m.id === `me_${timestamp}`);
|
|
@@ -26782,6 +28249,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26782
28249
|
size: file.size
|
|
26783
28250
|
});
|
|
26784
28251
|
const source = streamStored(stored);
|
|
28252
|
+
said();
|
|
26785
28253
|
await link.sendFile({
|
|
26786
28254
|
id: wireId,
|
|
26787
28255
|
name: file.name,
|
|
@@ -26794,7 +28262,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26794
28262
|
...reply && { reply },
|
|
26795
28263
|
...forwarded && { forwarded }
|
|
26796
28264
|
}, source);
|
|
26797
|
-
})().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|
|
28265
|
+
})().catch((error) => fail(error instanceof Error ? error.message : String(error))).finally(said);
|
|
28266
|
+
return offered;
|
|
26798
28267
|
}
|
|
26799
28268
|
fileAction({ linkId, fileId, action }) {
|
|
26800
28269
|
const live = this.links.get(linkId);
|
|
@@ -26823,13 +28292,39 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26823
28292
|
return link.supportsLargeFiles;
|
|
26824
28293
|
}
|
|
26825
28294
|
/**
|
|
26826
|
-
*
|
|
26827
|
-
*
|
|
28295
|
+
* A file that waited for the chat to be live goes: said on the open session (see `transferFile`), and this returns
|
|
28296
|
+
* then, so the chat's outbox sends it in its place among the texts that waited (WISP 400, requirement 10). A file
|
|
28297
|
+
* for an app that turns out not to take files fails with that reason instead of waiting forever (WISP 03).
|
|
28298
|
+
*/
|
|
28299
|
+
async sendWaitingFile(linkId, message) {
|
|
28300
|
+
const live = this.links.get(linkId), file = message.file;
|
|
28301
|
+
if (!live?.link?.isDataLinkOpen || !file) return;
|
|
28302
|
+
if (!GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
|
|
28303
|
+
if (!live.link.isDataLinkOpen) return;
|
|
28304
|
+
if (!GhostlyNode.takesFiles(live.link)) await db.updateDelivery(linkId, message.id, "failed", "Your contact's app cannot receive files.");
|
|
28305
|
+
else {
|
|
28306
|
+
await db.putMessage(sentNow(message));
|
|
28307
|
+
await this.transferFile(live, file, file.id.slice(`${linkId}-out-`.length), message.timestamp, (error) => {
|
|
28308
|
+
this.transfers.set(file.id, {
|
|
28309
|
+
state: "failed",
|
|
28310
|
+
transferred: 0,
|
|
28311
|
+
size: file.size,
|
|
28312
|
+
error
|
|
28313
|
+
});
|
|
28314
|
+
this.emitState();
|
|
28315
|
+
}, GhostlyNode.wireReply(message));
|
|
28316
|
+
}
|
|
28317
|
+
await this.messagesChanged(linkId, [message.id]);
|
|
28318
|
+
}
|
|
28319
|
+
/**
|
|
28320
|
+
* The chat is live: what waited for it goes now, in the order it was written. A 1:1 chat's files go with its texts,
|
|
28321
|
+
* through its outbox (`sendWaitingFile`); here its payment requests, and everything of any other chat.
|
|
26828
28322
|
*/
|
|
26829
28323
|
async sendWaiting(linkId) {
|
|
26830
28324
|
const live = this.links.get(linkId);
|
|
26831
28325
|
if (!live?.link?.isDataLinkOpen) return;
|
|
26832
|
-
const
|
|
28326
|
+
const withTexts = this.outboxFor(linkId).sendsFiles;
|
|
28327
|
+
const waiting = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && m.delivery === "waiting" && (m.file && !withTexts || m.paymentId)).sort((a, b) => a.timestamp - b.timestamp);
|
|
26833
28328
|
if (waiting.some((m) => m.file) && !GhostlyNode.takesFiles(live.link)) await GhostlyNode.largeFilesAgreed(live.link);
|
|
26834
28329
|
for (const message of waiting) if (message.file) {
|
|
26835
28330
|
if (!GhostlyNode.takesFiles(live.link)) {
|
|
@@ -27491,13 +28986,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27491
28986
|
...wireReaction(reaction),
|
|
27492
28987
|
...this.groups.signReaction(groupId, wireReaction(reaction))
|
|
27493
28988
|
};
|
|
27494
|
-
for (const edge of this.
|
|
28989
|
+
for (const edge of this.memberEdges(groupId).values()) try {
|
|
27495
28990
|
this.links.get(edge)?.link?.sendGroupFrame(frame);
|
|
27496
28991
|
} catch {}
|
|
27497
28992
|
return { error: null };
|
|
27498
28993
|
}
|
|
27499
28994
|
/** An edge of a private group opened: the member hears my latest reactions again, in case it missed them. */
|
|
27500
28995
|
async resendGroupReactions(groupId, linkId) {
|
|
28996
|
+
if (this.outsideEdge(linkId)) return;
|
|
27501
28997
|
const mine = groupReactionsToResend(await db.getMessages(`group:${groupId}`), REACTION_LIMITS.pending);
|
|
27502
28998
|
for (const reaction of mine) try {
|
|
27503
28999
|
this.links.get(linkId)?.link?.sendGroupFrame({
|
|
@@ -27557,7 +29053,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27557
29053
|
k: frame.k,
|
|
27558
29054
|
sig: frame.sig
|
|
27559
29055
|
});
|
|
27560
|
-
for (const member of this.
|
|
29056
|
+
for (const member of this.memberEdges(groupId).keys()) this.sendGroupPinFrame(groupId, member);
|
|
27561
29057
|
return { error: null };
|
|
27562
29058
|
}
|
|
27563
29059
|
const live = this.links.get(linkId);
|
|
@@ -27642,11 +29138,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27642
29138
|
sig: via.frame.sig
|
|
27643
29139
|
}
|
|
27644
29140
|
}) || !via) return;
|
|
27645
|
-
for (const key of this.
|
|
29141
|
+
for (const key of this.memberEdges(groupId).keys()) if (key !== via.from && key !== member) this.sendGroupPinFrame(groupId, key);
|
|
27646
29142
|
}
|
|
27647
29143
|
/** A private group's pin, as its pinner signed it, over the edge to `member`; nothing when it is down (said when it opens). */
|
|
27648
29144
|
sendGroupPinFrame(groupId, member) {
|
|
27649
|
-
const pin = this.groups.pinOf(groupId), edge = this.
|
|
29145
|
+
const pin = this.groups.pinOf(groupId), edge = this.memberEdges(groupId).get(member);
|
|
27650
29146
|
if (!pin?.k || !pin.sig || !edge || this.groups.isCommunityGroup(groupId) || !pinNumberHolds(pin.n)) return;
|
|
27651
29147
|
const frame = {
|
|
27652
29148
|
t: "group-pin",
|
|
@@ -27857,6 +29353,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27857
29353
|
if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
|
|
27858
29354
|
if (network !== "mainnet" && network !== "testnet") throw new Error("Choose Mainnet or Testnet");
|
|
27859
29355
|
await this.lightnings[network].start();
|
|
29356
|
+
if (type === "cashu") {
|
|
29357
|
+
let timer;
|
|
29358
|
+
await Promise.race([this.wallet.settleSwaps(this.networkMints(network)).catch(() => {}), new Promise((resolve) => {
|
|
29359
|
+
timer = setTimeout(resolve, removalTiming.claimMs);
|
|
29360
|
+
})]);
|
|
29361
|
+
clearTimeout(timer);
|
|
29362
|
+
}
|
|
27860
29363
|
await this.refreshWallet();
|
|
27861
29364
|
const card = type === "lightning" ? asked ?? this.lightnings[network].receivingId : void 0;
|
|
27862
29365
|
const lightningName = card !== void 0 ? this.walletView.networks?.[network].lightnings?.find((c) => c.card === card)?.name : void 0;
|
|
@@ -27875,7 +29378,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27875
29378
|
try {
|
|
27876
29379
|
if (type === "cashu") {
|
|
27877
29380
|
const mints = this.networkMints(network);
|
|
27878
|
-
await this.wallet.forget(mints);
|
|
29381
|
+
await this.wallet.forget(mints, acceptLoss === true);
|
|
27879
29382
|
await this.updateSettings({ settings: { mints: this.settings.mints.filter((m) => !mints.includes(m)) } });
|
|
27880
29383
|
} else if (type === "arkade") await this.arkWallets[network].remove();
|
|
27881
29384
|
else if (type === "bark") await this.barkWallets[network].remove();
|
|
@@ -28555,8 +30058,32 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28555
30058
|
payRequest(params) {
|
|
28556
30059
|
return this.desk.payRequest(params);
|
|
28557
30060
|
}
|
|
28558
|
-
reclaimPayment({ paymentId }) {
|
|
28559
|
-
|
|
30061
|
+
async reclaimPayment({ paymentId }) {
|
|
30062
|
+
await this.desk.reclaim(paymentId);
|
|
30063
|
+
await this.reviewTakenBack(paymentId);
|
|
30064
|
+
}
|
|
30065
|
+
/**
|
|
30066
|
+
* A reviewed payment whose ecash this wallet took back: its review ends failed, at once. The mint reads that ecash
|
|
30067
|
+
* spent (this wallet redeemed it), which the review would otherwise take for the contact being paid, and a request
|
|
30068
|
+
* whose review reads paid can never be reviewed again. Only when the ecash is back for certain (`reclaimed`).
|
|
30069
|
+
*/
|
|
30070
|
+
async reviewTakenBack(id) {
|
|
30071
|
+
if (this.desk.payment(id)?.state !== "reclaimed") return;
|
|
30072
|
+
const intent = await intentRepository.get(id);
|
|
30073
|
+
if (!intent || intent.review.method !== "cashu" || ![
|
|
30074
|
+
"submitted",
|
|
30075
|
+
"unknown",
|
|
30076
|
+
"settled"
|
|
30077
|
+
].includes(intent.review.state)) return;
|
|
30078
|
+
await intentRepository.put({
|
|
30079
|
+
...intent,
|
|
30080
|
+
review: {
|
|
30081
|
+
...intent.review,
|
|
30082
|
+
state: "failed",
|
|
30083
|
+
error: engineText("paymentTakenBack")
|
|
30084
|
+
}
|
|
30085
|
+
});
|
|
30086
|
+
this.emitState();
|
|
28560
30087
|
}
|
|
28561
30088
|
addService({ name, target }) {
|
|
28562
30089
|
const cleanName = name.trim().slice(0, 48);
|
|
@@ -28692,9 +30219,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28692
30219
|
this.publicActivity.clear();
|
|
28693
30220
|
}
|
|
28694
30221
|
}
|
|
28695
|
-
if (settings.avatar !== void 0 || settings.shareProfile !== void 0)
|
|
30222
|
+
if (settings.avatar !== void 0 || settings.shareProfile !== void 0) {
|
|
30223
|
+
for (const live of this.links.values()) if (!this.quietEdge(live.stored)) live.link?.setAvatar(this.sharedAvatar);
|
|
30224
|
+
}
|
|
28696
30225
|
if (settings.nick !== void 0 || settings.shareProfile !== void 0) {
|
|
28697
|
-
for (const live of this.links.values()) live.link?.setNick(this.sharedNick);
|
|
30226
|
+
for (const live of this.links.values()) if (!this.quietEdge(live.stored)) live.link?.setNick(this.sharedNick);
|
|
28698
30227
|
this.capsChanged();
|
|
28699
30228
|
}
|
|
28700
30229
|
if (settings.hyperdhtRelay !== void 0) {
|
|
@@ -28782,7 +30311,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28782
30311
|
const identity = identityFromSeedB64(invite.seedB64);
|
|
28783
30312
|
if (Date.now() - (this.warmedKeys.get(identity.pubKeyZ32) ?? 0) < SPARE_INVITE_MAX_AGE_MS) return;
|
|
28784
30313
|
this.warmedKeys.set(identity.pubKeyZ32, Date.now());
|
|
28785
|
-
this.
|
|
30314
|
+
this.warmKey(identity);
|
|
28786
30315
|
} catch {}
|
|
28787
30316
|
}
|
|
28788
30317
|
/** What this peer advertises. Chat, voice and video are what Ghostly always offered. */
|
|
@@ -28822,6 +30351,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28822
30351
|
this.links.delete(linkId);
|
|
28823
30352
|
this.edgeRtcOff.delete(linkId);
|
|
28824
30353
|
this.groupWakeReceived.delete(linkId);
|
|
30354
|
+
this.quietEdges.delete(linkId);
|
|
28825
30355
|
this.groupNativeWaiting.delete(linkId);
|
|
28826
30356
|
this.groupNativeRetryAt = 0;
|
|
28827
30357
|
if (live.stored.wakeToken && this.meshEdge(live.stored)) {
|
|
@@ -28943,11 +30473,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28943
30473
|
const { stored } = live;
|
|
28944
30474
|
const group = stored.group, peer = stored.groupPeer, entry = !!stored.groupEntry;
|
|
28945
30475
|
const role = stored.groupEntry ?? "edge";
|
|
30476
|
+
const member = () => entry || this.groups.isCommunityGroup(group) || this.groups.inRoster(group, peer);
|
|
28946
30477
|
let seen = false;
|
|
28947
30478
|
const native = this.keepsGroupNative(stored);
|
|
28948
30479
|
const resumeOn = native ? TRANSPORTS.find((t) => t !== "webrtc/1" && t in this.nativeFactories && !!stored.peerTransports?.includes(t)) : "webrtc/1";
|
|
28949
30480
|
live.pairing = { status: "connecting" };
|
|
28950
30481
|
live.link = new GhostLink({
|
|
30482
|
+
ownRecords: true,
|
|
28951
30483
|
paymentMethods: entry ? {
|
|
28952
30484
|
cashu: false,
|
|
28953
30485
|
lightning: false,
|
|
@@ -28985,7 +30517,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28985
30517
|
trustOnFirstUse: false
|
|
28986
30518
|
},
|
|
28987
30519
|
transport: this.groupTransport,
|
|
28988
|
-
nick: this.sharedNick,
|
|
30520
|
+
nick: !entry && this.quietEdge(stored) ? void 0 : this.sharedNick,
|
|
28989
30521
|
pollIntervals: entry || this.groups.isCommunityGroup(group) ? this.pollIntervals : meshEdgeIntervals(this.pollIntervals, () => this.groups.meshSize(group)),
|
|
28990
30522
|
autoConnect: true,
|
|
28991
30523
|
...entry ? {
|
|
@@ -29001,12 +30533,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29001
30533
|
getHostedHttpService: () => void 0,
|
|
29002
30534
|
groupsSupport: true,
|
|
29003
30535
|
events: {
|
|
29004
|
-
onGroupFrame: (frame) => entry ? this.groups.handleContactFrame(linkId, frame) : frame.t === "group-pay" ? this.groupPayments.receive(group, peer, frame) : frame.t === "group-wake" ? this.receiveGroupWake(linkId, frame) : this.groups.handleEdgeFrame(group, peer, frame),
|
|
30536
|
+
onGroupFrame: (frame) => entry ? this.groups.handleContactFrame(linkId, frame) : !member() ? this.groups.handleEdgeFrame(group, peer, frame) : frame.t === "group-pay" ? this.groupPayments.receive(group, peer, frame) : frame.t === "group-wake" ? this.receiveGroupWake(linkId, frame) : this.groups.handleEdgeFrame(group, peer, frame),
|
|
29005
30537
|
onGroupsSupport: (supported) => {
|
|
29006
30538
|
if (supported) traceJoin(group, "link.ready", { role });
|
|
29007
30539
|
if (!entry) this.noteEdgeLive(linkId, supported);
|
|
29008
30540
|
if (supported) {
|
|
29009
30541
|
if (entry) this.groups.entryReady(group, linkId, peer);
|
|
30542
|
+
else if (!member()) this.groups.edgeReady(group, peer, linkId);
|
|
29010
30543
|
else {
|
|
29011
30544
|
this.groups.edgeReady(group, peer, linkId);
|
|
29012
30545
|
this.groupPayments.edgeReady(group, linkId).catch(() => {});
|
|
@@ -29031,10 +30564,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29031
30564
|
if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
|
|
29032
30565
|
},
|
|
29033
30566
|
...entry ? {} : {
|
|
29034
|
-
onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
|
|
29035
|
-
onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
|
|
29036
|
-
onPayment: (payment) => this.desk.onPayment(linkId, payment),
|
|
29037
|
-
onPaymentResult: (result) => this.desk.onPaymentResult(linkId, result),
|
|
30567
|
+
onPaymentRequest: (request) => member() ? this.desk.onPaymentRequest(linkId, request) : void 0,
|
|
30568
|
+
onPaymentAsk: (ask) => member() ? this.desk.onPaymentAsk(linkId, ask) : void 0,
|
|
30569
|
+
onPayment: (payment) => member() ? this.desk.onPayment(linkId, payment) : void 0,
|
|
30570
|
+
onPaymentResult: (result) => member() ? this.desk.onPaymentResult(linkId, result) : void 0,
|
|
29038
30571
|
onPeerNick: (nick) => this.groups.edgeNick(group, peer, nick ?? void 0)
|
|
29039
30572
|
},
|
|
29040
30573
|
onPresence: (presence) => {
|
|
@@ -29061,7 +30594,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29061
30594
|
onDataLinkState: (state) => {
|
|
29062
30595
|
traceJoin(group, `link.${state}`, { role });
|
|
29063
30596
|
if (state === "open" || live.dataLink === "open") live.lastSyncAt = Date.now();
|
|
29064
|
-
if (state === "open" && !entry) this.
|
|
30597
|
+
if (state === "open" && !entry) this.groups.edgeOpen(group, peer);
|
|
30598
|
+
if (state === "open" && !entry && member()) this.desk.replay(linkId).catch(() => {});
|
|
29065
30599
|
live.dataLink = state;
|
|
29066
30600
|
if (entry && state !== "open" && live.entryDone) this.closeGroupLink(linkId).catch(() => {});
|
|
29067
30601
|
if (state !== "open" && live.pairing?.status !== "error") live.pairing = { status: "connecting" };
|
|
@@ -29350,6 +30884,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29350
30884
|
this.emitState();
|
|
29351
30885
|
},
|
|
29352
30886
|
onDirectEvidence: (evidence) => this.directPath.note(stored.peerPubKeyZ32, evidence),
|
|
30887
|
+
onPeerClock: (packetAt, readBefore, readAt) => {
|
|
30888
|
+
const peer = live.stored.pairedPeerKey;
|
|
30889
|
+
if (peer && !live.stored.group) this.clock.peer(peer, packetAt, readBefore, readAt);
|
|
30890
|
+
},
|
|
29353
30891
|
onDataLinkState: (state) => {
|
|
29354
30892
|
const was = live.dataLink;
|
|
29355
30893
|
live.dataLink = state;
|
|
@@ -29487,7 +31025,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29487
31025
|
};
|
|
29488
31026
|
},
|
|
29489
31027
|
changed: (record) => this.peerCapsChanged(linkId, record),
|
|
29490
|
-
published: () => live.link?.announceCapsRevision()
|
|
31028
|
+
published: () => live.link?.announceCapsRevision(),
|
|
31029
|
+
refused: (error) => traceLink(live.myPubKeyZ32, "caps-refused", { reason: error.reason })
|
|
29491
31030
|
});
|
|
29492
31031
|
if (stored.pairedPeerKey && nativeUp) {
|
|
29493
31032
|
const late = setTimeout(() => caps.start(), STARTUP_QUIET_MS);
|
|
@@ -29578,7 +31117,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29578
31117
|
get nativeFactories() {
|
|
29579
31118
|
const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
|
|
29580
31119
|
return {
|
|
29581
|
-
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-
|
|
31120
|
+
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-VM9STKLd.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
|
|
29582
31121
|
...this.options.nativeTransports,
|
|
29583
31122
|
...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
|
|
29584
31123
|
};
|
|
@@ -29889,14 +31428,22 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29889
31428
|
* chat: the time its sender says is kept beside it (`sentAt`) and shown, and orders nothing (WISP 400, requirement
|
|
29890
31429
|
* 10). A sender's clock ahead or behind then moves no message past another, and what I send next goes below what I
|
|
29891
31430
|
* just received. A row of mine keeps its own time; one placed already (it has `sentAt`) is left as it is. A line
|
|
29892
|
-
* of a group's history (a membership change, a rename) is not a message: it stays at its commit's time,
|
|
31431
|
+
* of a group's history (a membership change, a rename) is not a message: it stays at its commit's time, but never
|
|
31432
|
+
* ahead of this clock nor above a row placed here since the app started. The admin's clock picks that time: two
|
|
31433
|
+
* minutes ahead, "X joined" sat under everything said in the next two minutes; behind, it went above what the
|
|
31434
|
+
* members had just read. Read late with nothing newer here (a member back after a while), it keeps its moment.
|
|
29893
31435
|
*/
|
|
29894
31436
|
placed(message) {
|
|
29895
31437
|
const now = Date.now(), last = this.placedAt.get(message.linkId) ?? 0;
|
|
29896
|
-
if (message.event)
|
|
29897
|
-
|
|
29898
|
-
timestamp
|
|
29899
|
-
|
|
31438
|
+
if (message.event) {
|
|
31439
|
+
if (message.sender !== "peer") return message;
|
|
31440
|
+
const timestamp = Math.max(Math.min(receivedTimestamp(message.timestamp, now), now), last + 1);
|
|
31441
|
+
this.placedAt.set(message.linkId, timestamp);
|
|
31442
|
+
return {
|
|
31443
|
+
...message,
|
|
31444
|
+
timestamp
|
|
31445
|
+
};
|
|
31446
|
+
}
|
|
29900
31447
|
if (message.sender === "peer" && message.sentAt === void 0) {
|
|
29901
31448
|
const sentAt = claimedTime(message.timestamp);
|
|
29902
31449
|
message = {
|
|
@@ -29956,8 +31503,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
29956
31503
|
};
|
|
29957
31504
|
}
|
|
29958
31505
|
if (!await db.addMessage(message)) return false;
|
|
31506
|
+
this.clockSample(message);
|
|
29959
31507
|
const newest = this.newestAt.get(message.linkId);
|
|
29960
|
-
if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0,
|
|
31508
|
+
if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, writtenAt(message, this.clockLeads.get(message.linkId))));
|
|
29961
31509
|
if (message.sender === "peer") this.messageFeedback("message", message, newest);
|
|
29962
31510
|
if (live) live.lastMessageAt = Math.max(live.lastMessageAt, message.timestamp);
|
|
29963
31511
|
const early = message.sender === "peer" && message.id.startsWith("peer_") ? this.editBuffer.take(message.linkId, message.id.slice(5)) : void 0;
|
|
@@ -30042,7 +31590,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
30042
31590
|
status: live.status,
|
|
30043
31591
|
dataLink: live.dataLink,
|
|
30044
31592
|
peerOnline: presence.online,
|
|
30045
|
-
peerLastSeenAt: presence
|
|
31593
|
+
peerLastSeenAt: presenceSeenAt(presence),
|
|
30046
31594
|
peerServices: presence.services,
|
|
30047
31595
|
lastMessageAt: live.lastMessageAt,
|
|
30048
31596
|
peerAck: live.peerAck,
|
|
@@ -30131,6 +31679,7 @@ var EngineServer = class {
|
|
|
30131
31679
|
}
|
|
30132
31680
|
}, options);
|
|
30133
31681
|
this.ready = this.node.start();
|
|
31682
|
+
this.ready.catch(() => {});
|
|
30134
31683
|
}
|
|
30135
31684
|
attach(client) {
|
|
30136
31685
|
this.clients.add(client);
|
|
@@ -30156,6 +31705,11 @@ var EngineServer = class {
|
|
|
30156
31705
|
});
|
|
30157
31706
|
this.histories.get(client)?.add(link.id);
|
|
30158
31707
|
});
|
|
31708
|
+
}, (error) => {
|
|
31709
|
+
this.post(client, {
|
|
31710
|
+
kind: "start-failed",
|
|
31711
|
+
failure: profileOpenFailure(error)
|
|
31712
|
+
});
|
|
30159
31713
|
}).catch(() => {
|
|
30160
31714
|
this.clients.delete(client);
|
|
30161
31715
|
});
|
|
@@ -30263,4 +31817,4 @@ function signalKind(signal) {
|
|
|
30263
31817
|
//#endregion
|
|
30264
31818
|
export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
|
|
30265
31819
|
|
|
30266
|
-
//# sourceMappingURL=server-
|
|
31820
|
+
//# sourceMappingURL=server-BnNXYzR9.js.map
|