@ghostlytools/cli 1.0.0 → 1.0.1
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 +66 -21
- package/SKILL.md +99 -18
- package/dist/assets/{endpoint-h3cqRyRz.js → endpoint-CaSbSFDw.js} +2 -2
- package/dist/assets/{endpoint-h3cqRyRz.js.map → endpoint-CaSbSFDw.js.map} +1 -1
- package/dist/assets/{fileBytes-ByS16quP.js → fileBytes-CLv0gryb.js} +2 -2
- package/dist/assets/{fileBytes-ByS16quP.js.map → fileBytes-CLv0gryb.js.map} +1 -1
- package/dist/assets/{fileBytesIdb-COzoilo9.js → fileBytesIdb-CKCtzMTy.js} +2 -2
- package/dist/assets/{fileBytesIdb-COzoilo9.js.map → fileBytesIdb-CKCtzMTy.js.map} +1 -1
- package/dist/assets/{fileBytesOpfs-B27wAMUh.js → fileBytesOpfs-By55XVTI.js} +3 -3
- package/dist/assets/{fileBytesOpfs-B27wAMUh.js.map → fileBytesOpfs-By55XVTI.js.map} +1 -1
- package/dist/assets/{hyperdhtRelay-BKsFpPjr.js → hyperdhtRelay-_rJsllcP.js} +4 -4
- package/dist/assets/{hyperdhtRelay-BKsFpPjr.js.map → hyperdhtRelay-_rJsllcP.js.map} +1 -1
- package/dist/assets/{mainline-DOncVbJN.js → mainline-B0mUxYPH.js} +12 -5
- package/dist/assets/mainline-B0mUxYPH.js.map +1 -0
- package/dist/assets/{server-5T5l8hfy.js → server-C-8wbsAX.js} +1833 -319
- package/dist/assets/server-C-8wbsAX.js.map +1 -0
- package/dist/ghostly.mjs +24088 -564
- package/dist/ghostly.mjs.map +1 -1
- package/examples/call-echo.mjs +116 -0
- package/examples/claude-code-agent.sh +55 -0
- package/examples/echo-bot.mjs +21 -0
- package/examples/echo-bot.sh +15 -0
- package/examples/payment-bot.mjs +59 -0
- package/package.json +2 -1
- package/dist/assets/mainline-DOncVbJN.js.map +0 -1
- package/dist/assets/server-5T5l8hfy.js.map +0 -1
- package/dist/assets/src-DptDr2et.js +0 -20971
- package/dist/assets/src-DptDr2et.js.map +0 -1
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { $n as
|
|
2
|
-
import { $ as chosenResolver, A as isTestMint, At as __exportAll, B as reserveAdapterIds, C as cashuMint, Ct as deleteDatabase, D as DEFAULT_MINTS, Dt as transact, E as BITCOIN_INVOICE_ON_TESTNET, Et as store, F as MAX_BACKUP_BYTES, G as isNothingSpentError, H as PROVIDER_PLATFORMS, I as identityProviders, J as assertPublicServer, K as networkMode, L as onAdaptersChanged, M as mintNetwork, N as paysItsOwnInvoices, O as TEST_MINT, Ot as wrap, P as BACKUP_MEDIA_TYPE, Q as verifyIdentity, R as registeredLightningProviders, S as CASHU_MINT_SOURCE, St as databaseName, T as WALLET_TYPES, Tt as openDb, U as SourceConfigError, V as NothingSpentError, W as SourceUnreachableError, X as setOwnDidSource, Y as resolveAtprotoDid, Z as boundedIdentityFetch, _ as unsealSeed, a as createFailure, at as paymentAlias, b as WrongNetworkError, c as paymentNetwork, ct as readStored, d as CASHU_CARD, dt as streamStored, et as groupReply, f as LightningCards, g as sealSeed, gt as fileBytesOf, h as newDeviceKey, ht as fileBytes, i as assertConfirmedReal, it as replyTo, j as isWorthlessMint, k as fakesLightning, kt as decodeBolt11, l as paymentNetworksOf, lt as removeStored, m as intentRepository, n as loadFedimintSdk, nt as receivedPairedReply, o as createTiming, ot as paymentWireId, p as ProviderSources, pt as blobDigest, q as redact, r as WALLET_NAMES, rt as replyRef, s as crossNetwork, st as FileAppender, t as fedimintDatabase, tt as pairedWireReply, u as walletInstances, ut as storedSize, v as ModeChanged, vt as removeFileBytes, wt as fileStore, x as networkLabel, xt as STORES, y as ModeGate, z as registeredOnchainProviders } from "../ghostly.mjs";
|
|
1
|
+
import { $ as boundedIdentityFetch, $n as LIVENESS_PING_MS, $r as IDENTITY_REVOCATION_LABEL, $t as GroupWakeLimiter, A as TEST_MINT, Ai as ReactionWindow, An as assertTokenUnits, Ar as nextPinNumber, At as transact, B as registeredLightningProviders, Ba as toZ32, Bi as replySnippet, Bn as isPubkyKey, Br as vapidKeysMatch, Bt as communityHasFrame, C as networkLabel, Ca as normalizeRelayUrl, Ci as dhtEditId, Cn as ONCHAIN_PROVIDER, Cr as transferEnded, Ct as STORES, D as WALLET_TYPES, Da as PacketTooLargeError, Di as COMMUNITY_REACTION_FRAME, Dn as TEST_USDT_FAUCET_AMOUNT, Dt as fileStore, Ea as withRequestOptions, Ei as readForwarded, En as SEPOLIA_TEST_USDT_FAUCET, Er as typingActivity, Et as deleteDatabase, F as paysItsOwnInvoices, Fa as sanitizeAvatar, Fi as readReaction, Fn as SPARK_NETWORKS, Fr as WakeLimiter, Ft as openGroupTyping, G as SourceConfigError, Gi as statusCardText, Gn as cashuRequestPayload, Gr as HoldKeys, Gt as decodeGroupEntryLink, H as reserveAdapterIds, Ha as utf8Encode, Hi as STATUS_CARD_LIMITS, Hn as engineText, Hr as EDIT_CAPABILITY, Ht as decodeCommunityLink, I as BACKUP_MEDIA_TYPE, Ia as bytesEqual, Ii as validReactionNumber, In as SPARK_PROVIDER, Ir as newWakeToken, It as sealGroupTyping, J as networkMode, Jn as isFederationId, Jr as readManifest, Jt as knockIdentity, K as SourceUnreachableError, Ki as receivedTimestamp, Kn as fedimintRequestPayload, Kr as HoldRefusedError, Kt as encodeGroupEntryLink, L as MAX_BACKUP_BYTES, La as fromBase64Url, Li as wireReaction, Ln as sparkAddressKind, Lr as relayRequest, Lt as COMMUNITY_LIMITS, M as isTestMint, Ma as identityFromSeed, Mi as queueReaction, Mn as formatPaymentAmount, Mr as pinNumberHolds, N as isWorthlessMint, Na as identityFromSeedB64, Ni as reactionEmoji, Nn as validatePaymentTarget, Nr as readPin, Nt as GroupTypingBoard, O as BITCOIN_INVOICE_ON_TESTNET, Oi as GROUP_REACTION_FRAME, On as USDT_PUBLIC_RPC, Or as PIN_LIMITS, Ot as openDb, P as mintNetwork, Pa as publicKeyFromZ32, Pi as reactionIsNewer, Pn as walletNetworkOf, Pr as WAKE_CALL_INTERVAL_MS, Pt as groupTypingEpoch, Q as setOwnDidSource, Qi as edgeParams, Qn as INVITE_TAKEN, Qr as dialDescriptors, R as identityProviders, Ra as randomBytes, Ri as pairedReplyAuthor, Rn as sparkInvoiceDetails, Rr as wakeRequest, Rt as CommunitySession, S as WrongNetworkError, Sa as currentRelays, Si as RateWindow, Sn as EVM_TEST_CHAINS, Sr as FILE_LIMITS, T as cashuMint, Ta as isDiscoveryBudgetError, Ti as forwardedAgain, Tn as SEPOLIA_TEST_USDT, Tr as TypingSender, Tt as databaseName, U as NothingSpentError, Ua as __exportAll, Ui as checkStatusCard, Un as decodeBolt11, Ur as HOLD_CAPABILITY, Ut as encodeCommunityLink, V as registeredOnchainProviders, Va as utf8Decode, Vi as BUTTON_ID, Vn as engineError, Vt as communityMessageId, W as PROVIDER_PLATFORMS, Wi as sameButtonText, Wn as ENDPOINT, Wr as HOLD_LIMITS, X as assertPublicServer, Xi as rosterHas, Xn as parseFedimintRequestPayload, Xr as DHT_TEXT_CAPABILITY, Xt as mergeKnocks, Y as redact, Yi as MEMBER_KEY, Yn as parseCashuRequestPayload, Yr as CapsExchange, Yt as knockRecords, Z as resolveAtprotoDid, Zi as verifyCommitSignature, Zn as GhostLink, Zr as capsDescriptors, Zt as readKnocks, _ as newDeviceKey, _a as decodeInviteCode, _i as parseVideoMeta, _n as didDhtFromPublicKey, _r as readBeacon, _t as fileBytes, a as createFailure, aa as webLocalFetch, ai as receivedIdentityStatus, an as GroupSession, ar as COMMUNITY_TOPOLOGY, at as replyRef, b as ModeChanged, ba as DEFAULT_RELAYS, bi as EDIT_BUFFER_MS, bn as signDidDhtPacket, br as shouldBeHub, bt as removeFileBytes, c as paymentNetwork, ca as encodeControl, ci as PeerProofs, cr as doorHubs, ct as paymentWireId, d as networkReason, da as emptyLinkRecords, di as TRANSPORTS, dn as parseCommunityEdit, dr as lobbyRecords, dt as removeStored, ea as RELAY_POLL_INTERVALS, ei as IdentityExchange, en as groupWakeFrame, er as pairedMessageFrame, et as verifyIdentity, f as rpcHost, fa as HTTP_SERVICE_PROTO, fi as automaticTransport, fn as mentionsMember, fr as mergeBeacon, ft as storedSize, g as intentRepository, ga as createChatInvite, gi as parseVoiceMeta, gn as didDhtDocument, gr as rankHubs, h as ProviderSources, ha as OwnInviteError, hi as fileMessageText, hn as normalizeAtprotoHandle, hr as pickHubs, ht as blobDigest, i as assertConfirmedReal, ia as parseLocalTarget, ii as newIdentityBinding, in as GROUP_READ_NOTE, ir as pickMeshHubs, it as receivedPairedReply, j as fakesLightning, ja as createIdentity, ji as nextReactionNumber, jn as assertWholeSats, jr as pinIsNewer, jt as wrap, k as DEFAULT_MINTS, ki as REACTION_LIMITS, kn as WALLET_NETWORKS, kr as mayPin, kt as store, l as paymentNetworksOf, la as RTC_CONFIG, li as emptyProofLedger, ln as GROUP_EDIT_TEXT_BYTES, lr as freshHubs, lt as FileAppender, m as LightningCards, ma as serviceIdFromName, mi as safeBlobType, mn as isAtprotoDid, mr as newerHead, n as loadFedimintSdk, na as GhostlyHttpError, ni as identityRevocationValue, nn as parseGroupWakeFrame, nr as mayBeHub, nt as groupReply, o as createTiming, oa as LIMITS, oi as revokesIdentity, on as groupMessageId, or as beaconKeys, ot as replyTo, p as CASHU_CARD, pa as LEGACY_SERVICES, pi as parseImageMeta, pn as groupName, pr as mergeLobby, pt as streamStored, q as isNothingSpentError, qi as GROUP_MEMBER_CAP, qn as findEndpoint, qr as newMailbox, qt as entryParams, r as WALLET_NAMES, ra as formatLocalTarget, ri as identityStatement, rr as meshHubs, rt as pairedWireReply, s as crossNetwork, sa as decodeControl, si as lastSharedWithMe, sr as beaconRecords, st as paymentAlias, t as fedimintDatabase, ta as traceLink, ti as emptyIdentityLedger, tn as groupWakes, tr as MESH_HUBS, tt as chosenResolver, u as walletInstances, ua as parseCallSignal, ui as validExternalKey, un as carryMentions, ur as lobbyKeys, ut as readStored, v as sealSeed, va as encodeInviteCode, vi as DHT_MESSAGE_TTL, vn as encodeDidDhtDeactivated, vr as readBeaconHead, vt as fileBytesOf, w as CASHU_MINT_SOURCE, wa as budgetRetryMs, wi as parseLinkPreview, wn as PaymentPreflightError, wr as transferStalled, wt as clearProfileStores, x as ModeGate, xa as RelayTransport, xi as EDIT_RATE_WINDOW_MS, xn as ETHEREUM_USDT, xr as ChatFiles, y as unsealSeed, ya as inviteOwnership, yi as peekDhtMailbox, yn as encodeDidDhtPacket, yr as readLobby, z as onAdaptersChanged, za as toBase64Url, zi as readReply, zn as isBitcoinAddress, zr as checkPushEndpoint, zt as GROUP_READ_NOTE_COMMUNITY } from "../ghostly.mjs";
|
|
3
2
|
import { base64, base64url, base64urlnopad, bech32, hex } from "@scure/base";
|
|
4
3
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
5
4
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
@@ -11,7 +10,7 @@ import { finalizeEvent, getPublicKey, verifyEvent } from "nostr-tools/pure";
|
|
|
11
10
|
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
|
|
12
11
|
import WalletManagerEvm from "@tetherto/wdk-wallet-evm";
|
|
13
12
|
import { Interface, Transaction, getAddress, keccak256 } from "ethers";
|
|
14
|
-
import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
|
|
13
|
+
import { ArkAddress, EsploraProvider, IndexedDBContractRepository, IndexedDBWalletRepository, MnemonicIdentity, RestArkProvider, RestIndexerProvider, Transaction as Transaction$1, Wallet, assertAllowedSighashTypes, assertSubmittedArkTxid, canRecoverOnchain, isRecoverable, matchServerCheckpoints, verifyTapscriptSignatures } from "@arkade-os/sdk";
|
|
15
14
|
import { tapLeafHash } from "@scure/btc-signer/payment.js";
|
|
16
15
|
import { Transaction as Transaction$2 } from "@scure/btc-signer";
|
|
17
16
|
import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
@@ -157,18 +156,24 @@ var UsdtAdapter = class UsdtAdapter {
|
|
|
157
156
|
return adapter;
|
|
158
157
|
}
|
|
159
158
|
async rpc(method, params = []) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
159
|
+
let response;
|
|
160
|
+
try {
|
|
161
|
+
response = await fetch(this.config.provider, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
headers: { "Content-Type": "application/json" },
|
|
164
|
+
body: JSON.stringify({
|
|
165
|
+
jsonrpc: "2.0",
|
|
166
|
+
id: ++this.nextId,
|
|
167
|
+
method,
|
|
168
|
+
params
|
|
169
|
+
}),
|
|
170
|
+
signal: AbortSignal.timeout(15e3)
|
|
171
|
+
});
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const reason = networkReason(error, rpcHost(this.config.provider));
|
|
174
|
+
throw reason ? Object.assign(new Error(reason), { cause: error }) : error;
|
|
175
|
+
}
|
|
176
|
+
if (!response.ok) throw engineError("usdtRpcUnavailable");
|
|
172
177
|
const result = await response.json();
|
|
173
178
|
if (result.error || !Object.prototype.hasOwnProperty.call(result, "result")) throw new Error("USDT RPC rejected the operation");
|
|
174
179
|
return result.result;
|
|
@@ -206,7 +211,7 @@ var UsdtAdapter = class UsdtAdapter {
|
|
|
206
211
|
if (!block.baseFeePerGas) throw new Error("This wallet requires an EIP-1559 chain");
|
|
207
212
|
const priority = BigInt(await this.rpc("eth_maxPriorityFeePerGas"));
|
|
208
213
|
const maxFeePerGas = BigInt(block.baseFeePerGas) * 2n + priority, gasLimit = (estimate * 120n + 99n) / 100n;
|
|
209
|
-
if (BigInt((await this.balances()).gasBalance) < gasLimit * maxFeePerGas) throw
|
|
214
|
+
if (BigInt((await this.balances()).gasBalance) < gasLimit * maxFeePerGas) throw engineError("usdtNeedsSepoliaGas");
|
|
210
215
|
const raw = await account.signTransaction({
|
|
211
216
|
type: 2,
|
|
212
217
|
to: SEPOLIA_TEST_USDT_FAUCET,
|
|
@@ -269,7 +274,7 @@ var UsdtAdapter = class UsdtAdapter {
|
|
|
269
274
|
const from = await this.address(), to = this.config.token;
|
|
270
275
|
if (same(target.address, from) || same(target.address, to)) throw new Error("Choose a different recipient address");
|
|
271
276
|
const { balance, gasBalance } = await this.balances();
|
|
272
|
-
if (BigInt(balance) < BigInt(amount)) throw
|
|
277
|
+
if (BigInt(balance) < BigInt(amount)) throw engineError("usdtNotEnoughTokens");
|
|
273
278
|
const data = erc20.encodeFunctionData("transfer", [getAddress(target.address), BigInt(amount)]);
|
|
274
279
|
const nonce = Number(BigInt(await this.rpc("eth_getTransactionCount", [from, "pending"])));
|
|
275
280
|
if (!Number.isSafeInteger(nonce)) throw new Error("Invalid account nonce");
|
|
@@ -285,8 +290,8 @@ var UsdtAdapter = class UsdtAdapter {
|
|
|
285
290
|
const maxFee = BigInt(block.baseFeePerGas) * 2n + priority;
|
|
286
291
|
const gasLimit = (estimate * 120n + 99n) / 100n;
|
|
287
292
|
const maximum = gasLimit * maxFee;
|
|
288
|
-
if (maximum > BigInt(feeCap) || maximum > BigInt(Number.MAX_SAFE_INTEGER)) throw
|
|
289
|
-
if (BigInt(gasBalance) < maximum) throw
|
|
293
|
+
if (maximum > BigInt(feeCap) || maximum > BigInt(Number.MAX_SAFE_INTEGER)) throw engineError("usdtGasAboveLimit");
|
|
294
|
+
if (BigInt(gasBalance) < maximum) throw engineError("usdtNotEnoughGas");
|
|
290
295
|
const prepared = {
|
|
291
296
|
from,
|
|
292
297
|
to,
|
|
@@ -575,20 +580,21 @@ var UsdtWallet = class {
|
|
|
575
580
|
throw error;
|
|
576
581
|
}
|
|
577
582
|
this.saved = saved;
|
|
583
|
+
this.view = {
|
|
584
|
+
configured: true,
|
|
585
|
+
locked: true,
|
|
586
|
+
automatic: !!deviceKey,
|
|
587
|
+
...config,
|
|
588
|
+
balance: "0",
|
|
589
|
+
gasBalance: "0"
|
|
590
|
+
};
|
|
578
591
|
if (epoch !== this.epoch) {
|
|
579
592
|
await adapter.dispose();
|
|
580
|
-
this.view = {
|
|
581
|
-
configured: true,
|
|
582
|
-
locked: true,
|
|
583
|
-
automatic: !!deviceKey,
|
|
584
|
-
...config,
|
|
585
|
-
balance: "0",
|
|
586
|
-
gasBalance: "0"
|
|
587
|
-
};
|
|
588
593
|
this.changed();
|
|
589
594
|
return;
|
|
590
595
|
}
|
|
591
596
|
this.adapter = adapter;
|
|
597
|
+
this.changed();
|
|
592
598
|
await this.refresh();
|
|
593
599
|
}
|
|
594
600
|
async unlock(password) {
|
|
@@ -904,13 +910,50 @@ var ArkadeAdapter = class ArkadeAdapter {
|
|
|
904
910
|
async balance() {
|
|
905
911
|
return (await this.wallet.getBalance()).available;
|
|
906
912
|
}
|
|
907
|
-
/**
|
|
908
|
-
|
|
909
|
-
|
|
913
|
+
/**
|
|
914
|
+
* Sats whose batch expired before they were renewed, in three kinds, as a person can act on them:
|
|
915
|
+
* - `sweeping`: past their expiry, but the server has not swept their batch yet (with any swept ones: none can be
|
|
916
|
+
* recovered meanwhile). The SDK (0.4.76) counts them as
|
|
917
|
+
* recoverable already, but it settles an unswept coin without the forfeit the server still asks for (arkd wants
|
|
918
|
+
* one until the sweep): the batch fails ("missing forfeit transactions") and the server bans the coin for a while.
|
|
919
|
+
* The sweep waits for the chain's time to pass the expiry: an hour or more on a real network, the expiry's blocks
|
|
920
|
+
* on regtest.
|
|
921
|
+
* - `recoverable`: swept, and a recovery takes them now.
|
|
922
|
+
* - `small`: swept, but together under the server's dust limit: a recovery makes one new coin and the server makes
|
|
923
|
+
* none that small, so `recoverVtxos` answers "No recoverable VTXOs found". They wait for more coins to expire.
|
|
924
|
+
*/
|
|
925
|
+
async expired() {
|
|
926
|
+
const now = { timestamp: /* @__PURE__ */ new Date() };
|
|
927
|
+
let swept = 0, sweeping = 0;
|
|
928
|
+
for (const vtxo of await this.wallet.getVtxos({
|
|
929
|
+
withRecoverable: true,
|
|
930
|
+
withUnrolled: false
|
|
931
|
+
})) if (isRecoverable(vtxo)) swept += vtxo.value;
|
|
932
|
+
else if (!vtxo.isSwept && canRecoverOnchain(vtxo, now)) sweeping += vtxo.value;
|
|
933
|
+
const takes = !sweeping && swept > 0 && (await (await this.wallet.getVtxoManager()).getRecoverableBalance()).recoverable >= this.wallet.dustAmount;
|
|
934
|
+
return sweeping ? {
|
|
935
|
+
recoverable: 0,
|
|
936
|
+
sweeping: sweeping + swept,
|
|
937
|
+
small: 0
|
|
938
|
+
} : {
|
|
939
|
+
recoverable: takes ? swept : 0,
|
|
940
|
+
sweeping: 0,
|
|
941
|
+
small: takes ? 0 : swept
|
|
942
|
+
};
|
|
910
943
|
}
|
|
911
|
-
/**
|
|
944
|
+
/**
|
|
945
|
+
* Moves swept outputs back into the balance, through the server's next batch. Refused while any expired coin is
|
|
946
|
+
* not swept yet (`recoverVtxos` would name it too, and that batch fails for all of them), and when what is swept
|
|
947
|
+
* is too little for a batch.
|
|
948
|
+
*/
|
|
912
949
|
recover() {
|
|
913
|
-
return this.serial(async () =>
|
|
950
|
+
return this.serial(async () => {
|
|
951
|
+
const { recoverable, sweeping, small } = await this.expired();
|
|
952
|
+
if (sweeping) throw new Error("Expired coins can be recovered once the Ark server has swept their batch. Try again later.");
|
|
953
|
+
if (small) throw new Error("Too few expired sats to recover on their own. They are recovered with the next coins that expire.");
|
|
954
|
+
if (!recoverable) throw new Error("Nothing to recover");
|
|
955
|
+
return (await this.wallet.getVtxoManager()).recoverVtxos();
|
|
956
|
+
});
|
|
914
957
|
}
|
|
915
958
|
/** On-chain deposits land here; the SDK's settlement moves them into Ark once confirmed. */
|
|
916
959
|
boardingAddress() {
|
|
@@ -1510,7 +1553,11 @@ var ArkWallet = class {
|
|
|
1510
1553
|
}
|
|
1511
1554
|
const balance = await read("balance", () => adapter.balance(), this.view.balance);
|
|
1512
1555
|
const incoming = await read("incoming", () => adapter.incoming(), this.view.incoming);
|
|
1513
|
-
const recoverable = await read("recoverable", () => adapter.
|
|
1556
|
+
const { recoverable, sweeping, small } = await read("recoverable", () => adapter.expired(), {
|
|
1557
|
+
recoverable: this.view.recoverable,
|
|
1558
|
+
sweeping: this.view.sweeping,
|
|
1559
|
+
small: this.view.small
|
|
1560
|
+
});
|
|
1514
1561
|
if (this.adapter !== adapter) return;
|
|
1515
1562
|
this.view = {
|
|
1516
1563
|
configured: true,
|
|
@@ -1523,12 +1570,14 @@ var ArkWallet = class {
|
|
|
1523
1570
|
incoming,
|
|
1524
1571
|
balance,
|
|
1525
1572
|
recoverable,
|
|
1573
|
+
sweeping,
|
|
1574
|
+
small,
|
|
1526
1575
|
error: failed.length ? `Could not read the ${failed.join(", ")} from the Ark provider. Last values may be stale.` : void 0
|
|
1527
1576
|
};
|
|
1528
1577
|
this.changed();
|
|
1529
1578
|
if (this.adapter) this.timer = setTimeout(() => void this.refresh(), 1e4);
|
|
1530
1579
|
}
|
|
1531
|
-
/**
|
|
1580
|
+
/** Swept outputs back into the balance (refused while expired ones wait for the sweep); the next refresh shows them. */
|
|
1532
1581
|
async recover() {
|
|
1533
1582
|
const txid = await this.require().recover();
|
|
1534
1583
|
await this.refresh();
|
|
@@ -2355,7 +2404,7 @@ var NOTES_REFUND_SECS = 604800;
|
|
|
2355
2404
|
var MAX_SATS = 1e7;
|
|
2356
2405
|
var fedimintMode = (network) => network === "bitcoin" ? "mainnet" : "testnet";
|
|
2357
2406
|
var key = (mode) => `fedimintWallet-${mode}`;
|
|
2358
|
-
var sats$
|
|
2407
|
+
var sats$6 = (msats) => Math.floor(msats / 1e3);
|
|
2359
2408
|
function assertFedimintAmount(amount) {
|
|
2360
2409
|
if (!Number.isSafeInteger(amount) || amount <= 0) throw new Error("Enter an amount in sats");
|
|
2361
2410
|
if (amount > MAX_SATS) throw new Error(`Amounts above ${MAX_SATS.toLocaleString()} sats are not supported`);
|
|
@@ -2363,7 +2412,7 @@ function assertFedimintAmount(amount) {
|
|
|
2363
2412
|
/** An invite code as the client takes it: bech32m with the prefix `fed1` (so `fed11…`), one line, bounded. */
|
|
2364
2413
|
function normalizeInvite(text) {
|
|
2365
2414
|
const invite = text.trim();
|
|
2366
|
-
if (!/^fed11[02-9ac-hj-np-z]{20,2000}$/i.test(invite)) throw
|
|
2415
|
+
if (!/^fed11[02-9ac-hj-np-z]{20,2000}$/i.test(invite)) throw engineError("fedimintNotInvite");
|
|
2367
2416
|
return invite.toLowerCase();
|
|
2368
2417
|
}
|
|
2369
2418
|
/** A client operation as a line of history. */
|
|
@@ -2378,21 +2427,21 @@ function historyEntry(federation, op) {
|
|
|
2378
2427
|
};
|
|
2379
2428
|
const invoiceSats = () => {
|
|
2380
2429
|
const decoded = op.invoice ? decodeBolt11(op.invoice) : null;
|
|
2381
|
-
return decoded?.amountMsat ? sats$
|
|
2430
|
+
return decoded?.amountMsat ? sats$6(Number(decoded.amountMsat)) : void 0;
|
|
2382
2431
|
};
|
|
2383
2432
|
if (op.kind === "mint" && op.variant === "spend_o_o_b") {
|
|
2384
2433
|
const state = text === "UserCanceledSuccess" || text === "Refunded" ? "taken-back" : text === "UserCanceledFailure" || text === "Success" ? "done" : "pending";
|
|
2385
2434
|
return {
|
|
2386
2435
|
...base,
|
|
2387
2436
|
kind: "notes-out",
|
|
2388
|
-
amount: op.amountMsats !== void 0 ? sats$
|
|
2437
|
+
amount: op.amountMsats !== void 0 ? sats$6(op.amountMsats) : void 0,
|
|
2389
2438
|
state
|
|
2390
2439
|
};
|
|
2391
2440
|
}
|
|
2392
2441
|
if (op.kind === "mint" && op.variant === "reissuance") return {
|
|
2393
2442
|
...base,
|
|
2394
2443
|
kind: "notes-in",
|
|
2395
|
-
amount: op.amountMsats !== void 0 ? sats$
|
|
2444
|
+
amount: op.amountMsats !== void 0 ? sats$6(op.amountMsats) : void 0,
|
|
2396
2445
|
state: text === "Done" ? "done" : text === "Failed" ? "failed" : "pending"
|
|
2397
2446
|
};
|
|
2398
2447
|
if (op.kind === "ln" && op.variant === "receive") return {
|
|
@@ -2414,7 +2463,7 @@ function historyEntry(federation, op) {
|
|
|
2414
2463
|
if (op.kind === "wallet") return {
|
|
2415
2464
|
...base,
|
|
2416
2465
|
kind: "onchain",
|
|
2417
|
-
amount: op.amountMsats !== void 0 ? sats$
|
|
2466
|
+
amount: op.amountMsats !== void 0 ? sats$6(op.amountMsats) : void 0,
|
|
2418
2467
|
state: "pending"
|
|
2419
2468
|
};
|
|
2420
2469
|
}
|
|
@@ -2547,7 +2596,7 @@ var FedimintWallet = class {
|
|
|
2547
2596
|
render() {
|
|
2548
2597
|
const federations = (this.saved?.federations ?? []).map(({ database: _database, ...f }) => ({
|
|
2549
2598
|
...f,
|
|
2550
|
-
balance: sats$
|
|
2599
|
+
balance: sats$6(this.balances.get(f.id) ?? 0),
|
|
2551
2600
|
status: this.clients.has(f.id) ? "ready" : this.problems.has(f.id) ? "error" : "connecting",
|
|
2552
2601
|
error: this.problems.get(f.id),
|
|
2553
2602
|
lightning: f.modules.includes("ln")
|
|
@@ -2586,7 +2635,7 @@ var FedimintWallet = class {
|
|
|
2586
2635
|
if (!isFederationId(info.federationId)) throw new Error("The federation answered with an invalid id");
|
|
2587
2636
|
if (!info.modules.includes("mint")) throw new Error("This federation has no ecash module this app can use (the v1 mint module)");
|
|
2588
2637
|
if (!info.network) throw new Error("Could not tell which Bitcoin network this federation is on");
|
|
2589
|
-
if (fedimintMode(info.network) !== this.network) throw new WrongNetworkError(fedimintMode(info.network), fedimintMode(info.network) === "mainnet" ? "
|
|
2638
|
+
if (fedimintMode(info.network) !== this.network) throw new WrongNetworkError(fedimintMode(info.network), fedimintMode(info.network) === "mainnet" ? engineText("fedimintRealOnTestnet") : engineText("fedimintTestOnMainnet", { chain: info.network }));
|
|
2590
2639
|
}
|
|
2591
2640
|
/**
|
|
2592
2641
|
* Joins a federation. `recover`: the mnemonic was used with this federation before (a restore): the client rebuilds
|
|
@@ -2599,7 +2648,7 @@ var FedimintWallet = class {
|
|
|
2599
2648
|
const info = await this.gate.within(sdk.preview(code));
|
|
2600
2649
|
this.checkFederation(info);
|
|
2601
2650
|
const saved = this.saved ?? await this.newSeed();
|
|
2602
|
-
if (saved.federations.some((f) => f.id === info.federationId)) throw
|
|
2651
|
+
if (saved.federations.some((f) => f.id === info.federationId)) throw engineError("fedimintAlreadyJoined");
|
|
2603
2652
|
if (saved.federations.length >= 8) throw new Error(`At most 8 federations per wallet`);
|
|
2604
2653
|
const database = fedimintDatabase(crypto.randomUUID());
|
|
2605
2654
|
const mnemonic = await unsealSeed(saved.seed, saved.deviceKey);
|
|
@@ -2669,7 +2718,7 @@ var FedimintWallet = class {
|
|
|
2669
2718
|
if (!saved || !federation) throw new Error("Unknown federation");
|
|
2670
2719
|
const client = this.clients.get(federationId);
|
|
2671
2720
|
if (!client) throw new Error("Wait for this federation to connect: its balance must be read before leaving it");
|
|
2672
|
-
if (sats$
|
|
2721
|
+
if (sats$6(await client.balance()) > 0) throw new Error("This federation still holds your sats: spend or move them first");
|
|
2673
2722
|
if ((await intentRepository.list()).some((i) => i.review.method === "fedimint" && i.review.provider === federationId && [
|
|
2674
2723
|
"pending",
|
|
2675
2724
|
"submitted",
|
|
@@ -2677,7 +2726,7 @@ var FedimintWallet = class {
|
|
|
2677
2726
|
].includes(i.review.state))) throw new Error("A payment through this federation is not finished yet");
|
|
2678
2727
|
const out = (await client.operations(100)).filter((op) => historyEntry(federationId, op)?.kind === "notes-out" && historyEntry(federationId, op)?.state === "pending");
|
|
2679
2728
|
for (const op of out) if (await this.takeBack(federationId, op.id, 15e3) === "pending") throw new Error("Notes you sent from this federation are still being settled: try again in a moment");
|
|
2680
|
-
if (sats$
|
|
2729
|
+
if (sats$6(await client.balance()) > 0) throw new Error("Notes nobody redeemed came back to this federation: spend or move them first");
|
|
2681
2730
|
this.unsubscribe.get(federationId)?.();
|
|
2682
2731
|
this.unsubscribe.delete(federationId);
|
|
2683
2732
|
this.clients.delete(federationId);
|
|
@@ -2731,7 +2780,7 @@ var FedimintWallet = class {
|
|
|
2731
2780
|
const client = this.clients.get(federationId);
|
|
2732
2781
|
if (client) return client;
|
|
2733
2782
|
if (!this.saved?.federations.some((f) => f.id === federationId)) throw new Error(`You have not joined this federation in your ${this.network === "mainnet" ? "Mainnet" : "Testnet"} Fedimint wallet`);
|
|
2734
|
-
throw new Error(this.problems.get(federationId) ?? "
|
|
2783
|
+
throw new Error(this.problems.get(federationId) ?? engineText("fedimintConnecting"));
|
|
2735
2784
|
}
|
|
2736
2785
|
federation(federationId) {
|
|
2737
2786
|
return this.saved?.federations.find((f) => f.id === federationId);
|
|
@@ -2748,7 +2797,7 @@ var FedimintWallet = class {
|
|
|
2748
2797
|
async spendNotes(federationId, amount, ghostly = `wallet-${crypto.randomUUID()}`) {
|
|
2749
2798
|
assertFedimintAmount(amount);
|
|
2750
2799
|
const client = this.client(federationId);
|
|
2751
|
-
if (sats$
|
|
2800
|
+
if (sats$6(await client.balance()) < amount) throw engineError("fedimintNotEnough");
|
|
2752
2801
|
const spent = await client.spend(amount * 1e3, {
|
|
2753
2802
|
cancelAfterSecs: NOTES_REFUND_SECS,
|
|
2754
2803
|
ghostly
|
|
@@ -2793,7 +2842,7 @@ var FedimintWallet = class {
|
|
|
2793
2842
|
if (state !== "done") throw new Error("The federation is still redeeming these notes: they will show in the balance");
|
|
2794
2843
|
return {
|
|
2795
2844
|
federation: found.federation,
|
|
2796
|
-
amount: sats$
|
|
2845
|
+
amount: sats$6(found.amountMsats)
|
|
2797
2846
|
};
|
|
2798
2847
|
}
|
|
2799
2848
|
/** Takes notes back. `canceled`: they are ours again; `taken`: the recipient redeemed them first; `pending`: not settled yet. */
|
|
@@ -3156,12 +3205,12 @@ async function openBreez(params, sdk = loadBreezSdk, storage = breezStorage) {
|
|
|
3156
3205
|
/** Payments read to find one: newest first, bounded. */
|
|
3157
3206
|
var PAGE$1 = 100;
|
|
3158
3207
|
var PAGES$1 = 5;
|
|
3159
|
-
var sats$
|
|
3208
|
+
var sats$5 = (value) => {
|
|
3160
3209
|
const n = Number(value);
|
|
3161
3210
|
if (!Number.isSafeInteger(n) || n < 0) throw new Error("Spark returned an invalid amount");
|
|
3162
3211
|
return n;
|
|
3163
3212
|
};
|
|
3164
|
-
var message$
|
|
3213
|
+
var message$5 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : "Spark refused the payment";
|
|
3165
3214
|
var invoiceOf = (p) => p.details?.type === "spark" ? p.details.invoiceDetails?.invoice : void 0;
|
|
3166
3215
|
/** A fresh RFC 4122 v4 UUID: the SDK wants its idempotency keys in that form. */
|
|
3167
3216
|
var newSparkKey = () => crypto.randomUUID();
|
|
@@ -3218,7 +3267,7 @@ var SparkAdapter = class SparkAdapter {
|
|
|
3218
3267
|
return paymentRequest;
|
|
3219
3268
|
}
|
|
3220
3269
|
async balance() {
|
|
3221
|
-
return sats$
|
|
3270
|
+
return sats$5((await this.wallet.getInfo({ ensureSynced: false })).balanceSats);
|
|
3222
3271
|
}
|
|
3223
3272
|
/** Asks Spark's operators for what changed (transfers to claim, payments that completed). */
|
|
3224
3273
|
async sync() {
|
|
@@ -3232,8 +3281,8 @@ var SparkAdapter = class SparkAdapter {
|
|
|
3232
3281
|
return payments.map((p) => ({
|
|
3233
3282
|
id: p.id,
|
|
3234
3283
|
direction: p.paymentType === "send" ? "out" : "in",
|
|
3235
|
-
amount: sats$
|
|
3236
|
-
fee: sats$
|
|
3284
|
+
amount: sats$5(p.amount),
|
|
3285
|
+
fee: sats$5(p.fees),
|
|
3237
3286
|
at: p.timestamp * 1e3,
|
|
3238
3287
|
status: p.status,
|
|
3239
3288
|
via: p.method === "spark" ? "spark" : p.method === "lightning" ? "lightning" : p.method === "deposit" || p.method === "withdraw" ? "onchain" : "other",
|
|
@@ -3275,7 +3324,7 @@ var SparkAdapter = class SparkAdapter {
|
|
|
3275
3324
|
if (await this.balance() < review.amount + review.fee) throw new Error("Spark balance changed. Create a new review");
|
|
3276
3325
|
if (!persist) throw new Error("Durable payment journal required");
|
|
3277
3326
|
} catch (error) {
|
|
3278
|
-
throw new PaymentPreflightError(message$
|
|
3327
|
+
throw new PaymentPreflightError(message$5(error));
|
|
3279
3328
|
}
|
|
3280
3329
|
await persist();
|
|
3281
3330
|
try {
|
|
@@ -3287,7 +3336,7 @@ var SparkAdapter = class SparkAdapter {
|
|
|
3287
3336
|
} catch (error) {
|
|
3288
3337
|
const found = await this.sent(review, prepared).catch(() => void 0);
|
|
3289
3338
|
if (found) return this.outcome(found);
|
|
3290
|
-
throw new Error(/insufficient/i.test(message$
|
|
3339
|
+
throw new Error(/insufficient/i.test(message$5(error)) ? "Insufficient Spark balance" : "Spark payment outcome unknown");
|
|
3291
3340
|
}
|
|
3292
3341
|
});
|
|
3293
3342
|
}
|
|
@@ -3316,7 +3365,7 @@ var SparkAdapter = class SparkAdapter {
|
|
|
3316
3365
|
limit: PAGE$1,
|
|
3317
3366
|
sortAscending: false
|
|
3318
3367
|
});
|
|
3319
|
-
const found = payments.find((p) => p.status === "completed" && invoiceOf(p) === invoice && sats$
|
|
3368
|
+
const found = payments.find((p) => p.status === "completed" && invoiceOf(p) === invoice && sats$5(p.amount) >= amount && !claimed.has(p.id));
|
|
3320
3369
|
if (found) return found.id;
|
|
3321
3370
|
if (payments.length < PAGE$1) break;
|
|
3322
3371
|
}
|
|
@@ -3346,12 +3395,12 @@ var SparkAdapter = class SparkAdapter {
|
|
|
3346
3395
|
} else if (method.type === "sparkInvoice") {
|
|
3347
3396
|
const details = method.sparkInvoiceDetails;
|
|
3348
3397
|
if (details.invoice !== address || details.tokenIdentifier) throw new Error("Spark prepared a payment to another invoice");
|
|
3349
|
-
if (details.amount !== void 0 && sats$
|
|
3398
|
+
if (details.amount !== void 0 && sats$5(details.amount) !== amount) throw new Error("The Spark invoice is for another amount");
|
|
3350
3399
|
if (details.expiryTime !== void 0 && details.expiryTime * 1e3 < Date.now()) throw new Error("The Spark invoice has expired");
|
|
3351
3400
|
} else throw new Error("Spark would not pay this as a Spark transfer");
|
|
3352
|
-
if (sats$
|
|
3401
|
+
if (sats$5(quote.amount) !== amount) throw new Error("Spark prepared another amount");
|
|
3353
3402
|
return {
|
|
3354
|
-
fee: sats$
|
|
3403
|
+
fee: sats$5(method.fee),
|
|
3355
3404
|
kind: method.type === "sparkAddress" ? "address" : "invoice"
|
|
3356
3405
|
};
|
|
3357
3406
|
}
|
|
@@ -3755,12 +3804,12 @@ var PaymentCoordinator = class {
|
|
|
3755
3804
|
const validated = validatePaymentTarget(target);
|
|
3756
3805
|
if (validated.method === "usdt") assertTokenUnits(amount);
|
|
3757
3806
|
else assertWholeSats(amount);
|
|
3758
|
-
if (!Number.isSafeInteger(feeCap) || feeCap < 0) throw
|
|
3807
|
+
if (!Number.isSafeInteger(feeCap) || feeCap < 0) throw engineError("invalidMaxFee");
|
|
3759
3808
|
if (context.requestId) {
|
|
3760
|
-
if ((await this.repository.list()).find(({ review: r }) => r.requestId === context.requestId && r.linkId === context.linkId && !["failed", "cancelled"].includes(r.state))) throw
|
|
3809
|
+
if ((await this.repository.list()).find(({ review: r }) => r.requestId === context.requestId && r.linkId === context.linkId && !["failed", "cancelled"].includes(r.state))) throw engineError("requestHasPayment");
|
|
3761
3810
|
}
|
|
3762
3811
|
const { fee, prepared, evm } = await this.adapter(validated.method).prepare(validated, amount, feeCap);
|
|
3763
|
-
if (!Number.isSafeInteger(fee) || fee < 0 || fee > feeCap) throw
|
|
3812
|
+
if (!Number.isSafeInteger(fee) || fee < 0 || fee > feeCap) throw engineError("feeAboveLimit");
|
|
3764
3813
|
const review = {
|
|
3765
3814
|
...validated,
|
|
3766
3815
|
...context,
|
|
@@ -3782,7 +3831,7 @@ var PaymentCoordinator = class {
|
|
|
3782
3831
|
approve(id) {
|
|
3783
3832
|
return this.once(id, async () => {
|
|
3784
3833
|
const saved = await this.require(id);
|
|
3785
|
-
if (saved.review.state !== "pending") throw
|
|
3834
|
+
if (saved.review.state !== "pending") throw engineError("cannotSubmitAgain");
|
|
3786
3835
|
validatePaymentTarget(saved.review);
|
|
3787
3836
|
saved.review = (await this.repository.claim(id)).review;
|
|
3788
3837
|
this.changed(saved.review);
|
|
@@ -3797,7 +3846,7 @@ var PaymentCoordinator = class {
|
|
|
3797
3846
|
saved.review = {
|
|
3798
3847
|
...saved.review,
|
|
3799
3848
|
state: error instanceof PaymentPreflightError ? "failed" : "unknown",
|
|
3800
|
-
error: error instanceof PaymentPreflightError ? error.message : "
|
|
3849
|
+
error: error instanceof PaymentPreflightError ? error.message : engineText("outcomeUnknown")
|
|
3801
3850
|
};
|
|
3802
3851
|
}
|
|
3803
3852
|
await this.repository.put(saved);
|
|
@@ -3815,13 +3864,13 @@ var PaymentCoordinator = class {
|
|
|
3815
3864
|
...saved.review,
|
|
3816
3865
|
...result,
|
|
3817
3866
|
state: result.failed ? "failed" : result.settled ? "settled" : result.pending ? "submitted" : "unknown",
|
|
3818
|
-
error: result.failed ? result.error : result.settled ? void 0 : "
|
|
3867
|
+
error: result.failed ? result.error : result.settled ? void 0 : engineText("notYetConfirmed")
|
|
3819
3868
|
};
|
|
3820
3869
|
} catch {
|
|
3821
3870
|
saved.review = {
|
|
3822
3871
|
...saved.review,
|
|
3823
3872
|
state: "unknown",
|
|
3824
|
-
error: "
|
|
3873
|
+
error: engineText("couldNotVerify")
|
|
3825
3874
|
};
|
|
3826
3875
|
}
|
|
3827
3876
|
await this.repository.put(saved);
|
|
@@ -3837,14 +3886,31 @@ var PaymentCoordinator = class {
|
|
|
3837
3886
|
return saved.review;
|
|
3838
3887
|
});
|
|
3839
3888
|
}
|
|
3889
|
+
/**
|
|
3890
|
+
* Cancels the reviews no Approve can send any more, as Cancel does (what prepare reserved goes back, and each stays
|
|
3891
|
+
* in the store as cancelled): one past its expiry (approve refuses it), and one whose request was paid some other
|
|
3892
|
+
* way or closed (`unpayable`). Only pending reviews: a submitted or unknown one may hold or move money, and is only
|
|
3893
|
+
* ever reconciled. Returns what it cancelled.
|
|
3894
|
+
*/
|
|
3895
|
+
async dropStale(now = Date.now(), unpayable = () => false) {
|
|
3896
|
+
const dropped = [];
|
|
3897
|
+
for (const { review } of await this.repository.list()) {
|
|
3898
|
+
if (review.state !== "pending" || review.expiresAt > now && !unpayable(review)) continue;
|
|
3899
|
+
try {
|
|
3900
|
+
const cancelled = await this.cancel(review.id);
|
|
3901
|
+
if (cancelled.state === "cancelled") dropped.push(cancelled);
|
|
3902
|
+
} catch {}
|
|
3903
|
+
}
|
|
3904
|
+
return dropped;
|
|
3905
|
+
}
|
|
3840
3906
|
adapter(method) {
|
|
3841
3907
|
const adapter = this.adapters.find((a) => a.method === method);
|
|
3842
|
-
if (!adapter) throw
|
|
3908
|
+
if (!adapter) throw engineError("methodUnavailable");
|
|
3843
3909
|
return adapter;
|
|
3844
3910
|
}
|
|
3845
3911
|
async require(id) {
|
|
3846
3912
|
const saved = await this.repository.get(id);
|
|
3847
|
-
if (!saved) throw
|
|
3913
|
+
if (!saved) throw engineError("unknownPaymentIntent");
|
|
3848
3914
|
return saved;
|
|
3849
3915
|
}
|
|
3850
3916
|
once(id, work) {
|
|
@@ -3857,6 +3923,22 @@ var PaymentCoordinator = class {
|
|
|
3857
3923
|
};
|
|
3858
3924
|
//#endregion
|
|
3859
3925
|
//#region ../browser/src/engine/paymentAdapters/cashu.ts
|
|
3926
|
+
/**
|
|
3927
|
+
* How long one request to a mint may last: cashu-ts's default `requestTimeout` (5 min), which this wallet does not
|
|
3928
|
+
* change. Retries of a swap (NUT-19) run inside the same approval, and reconcile waits for it (the coordinator runs one
|
|
3929
|
+
* operation per payment at a time).
|
|
3930
|
+
*/
|
|
3931
|
+
var CASHU_REQUEST_TIMEOUT_MS = 3e5;
|
|
3932
|
+
/** The margin past that for a mint slow to act on a swap it did receive. */
|
|
3933
|
+
var SWAP_SETTLED_MS = 12e4;
|
|
3934
|
+
/**
|
|
3935
|
+
* When a swap whose outcome is unknown can no longer reach the mint: a request's longest life after the approval
|
|
3936
|
+
* (`submittedAt`, saved when it was claimed), and the margin after the attempt ended (`attemptEndedAt`), both saved.
|
|
3937
|
+
*/
|
|
3938
|
+
function swapSettledAt(review, prepared) {
|
|
3939
|
+
return Math.max((review.submittedAt ?? review.createdAt) + CASHU_REQUEST_TIMEOUT_MS + SWAP_SETTLED_MS, (prepared.attemptEndedAt ?? 0) + SWAP_SETTLED_MS);
|
|
3940
|
+
}
|
|
3941
|
+
var NEVER_REACHED_MINT = "The payment never reached the mint: nothing was sent, and the sats are back in your wallet. You can pay again.";
|
|
3860
3942
|
var CashuAdapter = class {
|
|
3861
3943
|
wallet;
|
|
3862
3944
|
publish;
|
|
@@ -3868,21 +3950,45 @@ var CashuAdapter = class {
|
|
|
3868
3950
|
async prepare(target, amount, feeCap) {
|
|
3869
3951
|
if (target.method !== "cashu" || target.network !== (mintNetwork(target.provider) === "testnet" ? "cashu-test" : "bitcoin")) throw new Error("Cashu mint/network mismatch");
|
|
3870
3952
|
const result = await this.wallet.prepareReviewedCashu(target.provider, amount);
|
|
3871
|
-
if (result.fee > feeCap) throw
|
|
3953
|
+
if (result.fee > feeCap) throw engineError("cashuFeeAboveLimit");
|
|
3872
3954
|
return result;
|
|
3873
3955
|
}
|
|
3874
3956
|
async execute(review, prepared, persist) {
|
|
3875
3957
|
if (!review.linkId) throw new Error("Cashu requires an authenticated chat recipient");
|
|
3876
|
-
|
|
3958
|
+
let token;
|
|
3959
|
+
try {
|
|
3960
|
+
token = await this.wallet.executeReviewedCashu(review, prepared);
|
|
3961
|
+
} catch (error) {
|
|
3962
|
+
prepared.attemptEndedAt = Date.now();
|
|
3963
|
+
throw error;
|
|
3964
|
+
}
|
|
3877
3965
|
await persist?.();
|
|
3878
3966
|
await this.publish(review, token);
|
|
3879
3967
|
return { settled: await this.wallet.reviewedCashuSpent(prepared) };
|
|
3880
3968
|
}
|
|
3881
|
-
|
|
3969
|
+
/** `persist` saves the intent as it is: required, since the abandon path must be durable before it releases anything. */
|
|
3970
|
+
async reconcile(review, prepared, persist) {
|
|
3971
|
+
if (prepared.abandoned) return this.abandon(prepared, persist);
|
|
3882
3972
|
if (await this.wallet.reviewedCashuSpent(prepared)) return { settled: true };
|
|
3883
3973
|
const token = prepared.token ?? await this.wallet.recoverReviewedCashu(review, prepared);
|
|
3884
3974
|
if (token) await this.publish(review, token);
|
|
3885
|
-
|
|
3975
|
+
if (await this.wallet.reviewedCashuSpent(prepared)) return { settled: true };
|
|
3976
|
+
if (!token && Date.now() >= swapSettledAt(review, prepared) && await this.wallet.reviewedCashuNeverSwapped(prepared)) return this.abandon(prepared, persist);
|
|
3977
|
+
return { settled: false };
|
|
3978
|
+
}
|
|
3979
|
+
/**
|
|
3980
|
+
* The mint proved this swap never happened. Marked for good first (a reload repeats only this), then its reserved
|
|
3981
|
+
* inputs are released. Nothing sends the saved swap again: approve claims pending reviews only, and reconcile asks.
|
|
3982
|
+
*/
|
|
3983
|
+
async abandon(prepared, persist) {
|
|
3984
|
+
prepared.abandoned = true;
|
|
3985
|
+
await persist();
|
|
3986
|
+
await this.wallet.releaseReviewedCashu(prepared);
|
|
3987
|
+
return {
|
|
3988
|
+
settled: false,
|
|
3989
|
+
failed: true,
|
|
3990
|
+
error: NEVER_REACHED_MINT
|
|
3991
|
+
};
|
|
3886
3992
|
}
|
|
3887
3993
|
};
|
|
3888
3994
|
//#endregion
|
|
@@ -4085,12 +4191,12 @@ var PAGES = 10;
|
|
|
4085
4191
|
/** Without any record of a payment, it is called failed only this long after its invoice expired. */
|
|
4086
4192
|
var LOST_AFTER_MS = 36e5;
|
|
4087
4193
|
var hex$8 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
4088
|
-
var sats$
|
|
4194
|
+
var sats$4 = (value) => {
|
|
4089
4195
|
const n = Number(value);
|
|
4090
4196
|
if (!Number.isSafeInteger(n) || n < 0) throw new Error("Breez returned an invalid amount");
|
|
4091
4197
|
return n;
|
|
4092
4198
|
};
|
|
4093
|
-
var message$
|
|
4199
|
+
var message$4 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : "Breez refused the payment";
|
|
4094
4200
|
/**
|
|
4095
4201
|
* The idempotency key of one attempt at paying one invoice: asking the SDK twice with it pays once.
|
|
4096
4202
|
* A new attempt (after one that failed) gets a new key, or the SDK would only hand back the failure.
|
|
@@ -4146,7 +4252,7 @@ var BreezLightning = class BreezLightning {
|
|
|
4146
4252
|
const { balanceSats } = await this.wallet.getInfo({ ensureSynced: false });
|
|
4147
4253
|
return {
|
|
4148
4254
|
network: this.network,
|
|
4149
|
-
balance: sats$
|
|
4255
|
+
balance: sats$4(balanceSats)
|
|
4150
4256
|
};
|
|
4151
4257
|
}
|
|
4152
4258
|
async createInvoice(amount, memo) {
|
|
@@ -4169,7 +4275,7 @@ var BreezLightning = class BreezLightning {
|
|
|
4169
4275
|
const payment = await this.find("receive", invoice.paymentHash, invoice.invoice);
|
|
4170
4276
|
if (payment?.status === "completed") return {
|
|
4171
4277
|
state: "paid",
|
|
4172
|
-
amount: sats$
|
|
4278
|
+
amount: sats$4(payment.amount)
|
|
4173
4279
|
};
|
|
4174
4280
|
if (payment?.status === "pending") return { state: "open" };
|
|
4175
4281
|
return { state: invoice.expiresAt < Date.now() ? "expired" : "open" };
|
|
@@ -4200,9 +4306,9 @@ var BreezLightning = class BreezLightning {
|
|
|
4200
4306
|
const fee = this.feeOf(prepared);
|
|
4201
4307
|
if (fee > maxFee) throw new Error(`The Lightning fee (${fee} sats) is above your limit (${maxFee})`);
|
|
4202
4308
|
const { balanceSats } = await this.wallet.getInfo({ ensureSynced: false });
|
|
4203
|
-
if (sats$
|
|
4309
|
+
if (sats$4(balanceSats) < amount + fee) throw new Error(`Not enough sats in the Breez wallet (${sats$4(balanceSats)}; ${amount + fee} needed)`);
|
|
4204
4310
|
} catch (error) {
|
|
4205
|
-
throw isNothingSpentError(error) ? error : new NothingSpentError(message$
|
|
4311
|
+
throw isNothingSpentError(error) ? error : new NothingSpentError(message$4(error));
|
|
4206
4312
|
}
|
|
4207
4313
|
const { payment } = await this.wallet.sendPayment({
|
|
4208
4314
|
prepareResponse: prepared,
|
|
@@ -4230,7 +4336,7 @@ var BreezLightning = class BreezLightning {
|
|
|
4230
4336
|
const state = payment.status === "completed" ? "paid" : payment.status === "failed" ? "failed" : "pending";
|
|
4231
4337
|
return {
|
|
4232
4338
|
state,
|
|
4233
|
-
fee: state === "paid" ? sats$
|
|
4339
|
+
fee: state === "paid" ? sats$4(payment.fees) : void 0,
|
|
4234
4340
|
preimage: preimageOf(payment)
|
|
4235
4341
|
};
|
|
4236
4342
|
}
|
|
@@ -4247,18 +4353,18 @@ var BreezLightning = class BreezLightning {
|
|
|
4247
4353
|
} });
|
|
4248
4354
|
const method = prepared.paymentMethod;
|
|
4249
4355
|
if (method.type !== "bolt11Invoice") throw new Error("Breez would not pay this as a Lightning invoice");
|
|
4250
|
-
if (method.invoiceDetails.paymentHash !== decodeBolt11(invoice)?.paymentHash || sats$
|
|
4356
|
+
if (method.invoiceDetails.paymentHash !== decodeBolt11(invoice)?.paymentHash || sats$4(prepared.amount) !== amount) throw new Error("Breez prepared a payment that does not match the invoice");
|
|
4251
4357
|
return prepared;
|
|
4252
4358
|
}
|
|
4253
4359
|
feeOf(prepared) {
|
|
4254
4360
|
if (prepared.paymentMethod.type !== "bolt11Invoice") throw new Error("Breez would not pay this as a Lightning invoice");
|
|
4255
|
-
return sats$
|
|
4361
|
+
return sats$4(prepared.paymentMethod.lightningFeeSats);
|
|
4256
4362
|
}
|
|
4257
4363
|
/** `failed` is never an answer here: a payment the SDK gave up on is pending until `paymentStatus` says so. */
|
|
4258
4364
|
result(payment) {
|
|
4259
4365
|
if (payment.status === "completed") return {
|
|
4260
4366
|
state: "paid",
|
|
4261
|
-
fee: sats$
|
|
4367
|
+
fee: sats$4(payment.fees),
|
|
4262
4368
|
preimage: preimageOf(payment),
|
|
4263
4369
|
ref: payment.id
|
|
4264
4370
|
};
|
|
@@ -4427,8 +4533,8 @@ var unhex = (text) => {
|
|
|
4427
4533
|
if (!/^([0-9a-f]{2})+$/.test(text)) throw new Error("Not hex");
|
|
4428
4534
|
return Uint8Array.from(text.match(/../g), (h) => parseInt(h, 16));
|
|
4429
4535
|
};
|
|
4430
|
-
var sats$
|
|
4431
|
-
var message$
|
|
4536
|
+
var sats$3 = (amount) => Number(amount.to_sat());
|
|
4537
|
+
var message$3 = (error) => error instanceof Error ? error.message : typeof error === "string" ? error : error?.message ? String(error.message) : String(error);
|
|
4432
4538
|
/** Checks the settings before anything is contacted. */
|
|
4433
4539
|
function parseBdkSettings({ config, secrets }) {
|
|
4434
4540
|
const network = config.network;
|
|
@@ -4502,7 +4608,7 @@ var Esplora = class Esplora {
|
|
|
4502
4608
|
if (this.signal.aborted) throw error;
|
|
4503
4609
|
if (timedOut) throw new SourceUnreachableError(`the Esplora server at ${this.host} did not answer in ${HTTP_TIMEOUT / 1e3} s`, error);
|
|
4504
4610
|
if (this.local) throw new SourceUnreachableError(`nothing answers at ${this.host}: the local Esplora server is not running`, error);
|
|
4505
|
-
throw new SourceUnreachableError(`the Esplora server at ${this.host} did not answer (${message$
|
|
4611
|
+
throw new SourceUnreachableError(`the Esplora server at ${this.host} did not answer (${message$3(error)})`, error);
|
|
4506
4612
|
}
|
|
4507
4613
|
const reader = response.body?.getReader();
|
|
4508
4614
|
let body = "";
|
|
@@ -4557,7 +4663,7 @@ var Esplora = class Esplora {
|
|
|
4557
4663
|
if (errors.length < servers.length) return;
|
|
4558
4664
|
if (signal.aborted) return reject(errors[0]);
|
|
4559
4665
|
if (errors.every((e) => e instanceof SourceConfigError)) return reject(errors[0]);
|
|
4560
|
-
reject(new SourceUnreachableError(`no public ${network} Esplora server answered (${servers.map((s) => new URL(s).host).join(", ")}): ${message$
|
|
4666
|
+
reject(new SourceUnreachableError(`no public ${network} Esplora server answered (${servers.map((s) => new URL(s).host).join(", ")}): ${message$3(errors[0])}`, errors[0]));
|
|
4561
4667
|
});
|
|
4562
4668
|
}
|
|
4563
4669
|
});
|
|
@@ -4616,7 +4722,7 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4616
4722
|
try {
|
|
4617
4723
|
parsed = parseBdkSettings(settings);
|
|
4618
4724
|
} catch (error) {
|
|
4619
|
-
throw new SourceConfigError(message$
|
|
4725
|
+
throw new SourceConfigError(message$3(error), error);
|
|
4620
4726
|
}
|
|
4621
4727
|
const { mnemonic, ...found } = parsed;
|
|
4622
4728
|
const fetcher = deps.fetch ?? globalThis.fetch.bind(globalThis);
|
|
@@ -4633,7 +4739,7 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4633
4739
|
wallet = bdk.Wallet.create(chain, pair.external, pair.internal);
|
|
4634
4740
|
publicDescriptor = wallet.public_descriptor("external");
|
|
4635
4741
|
} catch (error) {
|
|
4636
|
-
throw new SourceConfigError(`bad descriptor: the recovery phrase does not make a ${SCRIPT[config.script].label} wallet (${message$
|
|
4742
|
+
throw new SourceConfigError(`bad descriptor: the recovery phrase does not make a ${SCRIPT[config.script].label} wallet (${message$3(error)})`, error);
|
|
4637
4743
|
}
|
|
4638
4744
|
const fingerprint = /\[([0-9a-f]{8})\//.exec(publicDescriptor)?.[1] ?? "";
|
|
4639
4745
|
const key = `bdkWallet-${config.network}-${hex$7(sha256(new TextEncoder().encode(publicDescriptor))).slice(0, 32)}`;
|
|
@@ -4673,7 +4779,7 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4673
4779
|
this.syncedAt = Date.now();
|
|
4674
4780
|
this.persist();
|
|
4675
4781
|
})().catch((error) => {
|
|
4676
|
-
throw Object.assign(/* @__PURE__ */ new Error(`Could not sync from Esplora: ${message$
|
|
4782
|
+
throw Object.assign(/* @__PURE__ */ new Error(`Could not sync from Esplora: ${message$3(error)}`), { cause: error });
|
|
4677
4783
|
}).finally(() => {
|
|
4678
4784
|
this.syncing = void 0;
|
|
4679
4785
|
});
|
|
@@ -4687,8 +4793,8 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4687
4793
|
await this.sync();
|
|
4688
4794
|
const balance = this.wallet.balance;
|
|
4689
4795
|
return {
|
|
4690
|
-
confirmed: sats$
|
|
4691
|
-
unconfirmed: sats$
|
|
4796
|
+
confirmed: sats$3(balance.confirmed),
|
|
4797
|
+
unconfirmed: sats$3(balance.trusted_pending) + sats$3(balance.untrusted_pending)
|
|
4692
4798
|
};
|
|
4693
4799
|
}
|
|
4694
4800
|
async prepareSend(request) {
|
|
@@ -4714,9 +4820,9 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4714
4820
|
} catch (error) {
|
|
4715
4821
|
const data = error.data;
|
|
4716
4822
|
if (error.code === bdk.BdkErrorCode.InsufficientFunds && data) throw Object.assign(/* @__PURE__ */ new Error(`Not enough confirmed sats: ${data.available ?? 0} available, ${data.needed ?? "more"} needed with the fee`), { cause: error });
|
|
4717
|
-
throw Object.assign(/* @__PURE__ */ new Error(`Could not build the transaction: ${message$
|
|
4823
|
+
throw Object.assign(/* @__PURE__ */ new Error(`Could not build the transaction: ${message$3(error)}`), { cause: error });
|
|
4718
4824
|
}
|
|
4719
|
-
const fee = sats$
|
|
4825
|
+
const fee = sats$3(psbt.fee());
|
|
4720
4826
|
if (fee > feeCap) throw new Error(`The fee (${fee} sats) is above your limit of ${feeCap}`);
|
|
4721
4827
|
if (!this.wallet.sign(psbt, new bdk.SignOptions())) throw new Error("Could not sign the transaction");
|
|
4722
4828
|
const tx = psbt.extract_tx();
|
|
@@ -4741,14 +4847,14 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4741
4847
|
check(tx, recipient, amount, fee) {
|
|
4742
4848
|
let paid = 0, outgoing = 0;
|
|
4743
4849
|
for (const output of tx.output) {
|
|
4744
|
-
const value = sats$
|
|
4850
|
+
const value = sats$3(output.value);
|
|
4745
4851
|
if (output.script_pubkey.to_hex_string() === recipient && value === amount && !paid) {
|
|
4746
4852
|
paid++;
|
|
4747
4853
|
continue;
|
|
4748
4854
|
}
|
|
4749
4855
|
if (!this.wallet.is_mine(output.script_pubkey)) outgoing++;
|
|
4750
4856
|
}
|
|
4751
|
-
if (paid !== 1 || outgoing !== 0 || sats$
|
|
4857
|
+
if (paid !== 1 || outgoing !== 0 || sats$3(this.wallet.calculate_fee(tx)) !== fee) throw new Error("The signed transaction does not match the payment");
|
|
4752
4858
|
}
|
|
4753
4859
|
async estimateFeeRate() {
|
|
4754
4860
|
try {
|
|
@@ -4774,7 +4880,7 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4774
4880
|
headers: { "content-type": "text/plain" }
|
|
4775
4881
|
});
|
|
4776
4882
|
} catch (error) {
|
|
4777
|
-
throw Object.assign(/* @__PURE__ */ new Error(`No answer from the Esplora server: ${message$
|
|
4883
|
+
throw Object.assign(/* @__PURE__ */ new Error(`No answer from the Esplora server: ${message$3(error)}`), { cause: error });
|
|
4778
4884
|
}
|
|
4779
4885
|
if (answer.status === 200 && answer.body.trim() === prepared.txid) return this.sent(tx, prepared.txid);
|
|
4780
4886
|
if (answer.status === 200) throw new Error("The Esplora server answered with another transaction id");
|
|
@@ -4784,7 +4890,7 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4784
4890
|
try {
|
|
4785
4891
|
known = await this.esplora.txStatus(prepared.txid);
|
|
4786
4892
|
} catch (error) {
|
|
4787
|
-
throw Object.assign(/* @__PURE__ */ new Error(`The node refused the transaction, and it could not be checked: ${message$
|
|
4893
|
+
throw Object.assign(/* @__PURE__ */ new Error(`The node refused the transaction, and it could not be checked: ${message$3(error)}`), { cause: error });
|
|
4788
4894
|
}
|
|
4789
4895
|
if (known) return this.sent(tx, prepared.txid);
|
|
4790
4896
|
this.unreserve(prepared.txid);
|
|
@@ -4838,10 +4944,10 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4838
4944
|
await this.sync();
|
|
4839
4945
|
const tip = this.wallet.latest_checkpoint.height;
|
|
4840
4946
|
return this.wallet.transactions().map((wtx) => {
|
|
4841
|
-
const [sent, received] = [sats$
|
|
4947
|
+
const [sent, received] = [sats$3(this.wallet.sent_and_received(wtx.tx)[0]), sats$3(this.wallet.sent_and_received(wtx.tx)[1])];
|
|
4842
4948
|
let fee;
|
|
4843
4949
|
if (sent > 0) try {
|
|
4844
|
-
fee = sats$
|
|
4950
|
+
fee = sats$3(this.wallet.calculate_fee(wtx.tx));
|
|
4845
4951
|
} catch {
|
|
4846
4952
|
fee = void 0;
|
|
4847
4953
|
}
|
|
@@ -4864,7 +4970,7 @@ var BdkOnchain = class BdkOnchain {
|
|
|
4864
4970
|
const tip = this.wallet.latest_checkpoint.height;
|
|
4865
4971
|
const paid = [];
|
|
4866
4972
|
for (const wtx of this.wallet.transactions()) {
|
|
4867
|
-
const amount = wtx.tx.output.reduce((sum, output) => sum + (output.script_pubkey.to_hex_string() === script ? sats$
|
|
4973
|
+
const amount = wtx.tx.output.reduce((sum, output) => sum + (output.script_pubkey.to_hex_string() === script ? sats$3(output.value) : 0), 0);
|
|
4868
4974
|
if (!amount) continue;
|
|
4869
4975
|
const height = wtx.chain_position.anchor?.block_id.height;
|
|
4870
4976
|
paid.push({
|
|
@@ -5406,7 +5512,7 @@ var refOf = (ref) => {
|
|
|
5406
5512
|
if (!parsed?.op) throw new Error("Unknown Fedimint operation");
|
|
5407
5513
|
return parsed;
|
|
5408
5514
|
};
|
|
5409
|
-
var message$
|
|
5515
|
+
var message$2 = (error) => error instanceof Error ? error.message : String(error);
|
|
5410
5516
|
/** What the client says before any contract is funded: nothing left the wallet. */
|
|
5411
5517
|
var BEFORE_FUNDING = /insufficient|not enough|no gateway|no available gateway|expired|invalid|amount|already paid|parse|decode/i;
|
|
5412
5518
|
/**
|
|
@@ -5435,7 +5541,7 @@ var FedimintLightning = class {
|
|
|
5435
5541
|
try {
|
|
5436
5542
|
return this.wallet.client(this.federationId);
|
|
5437
5543
|
} catch (error) {
|
|
5438
|
-
throw new SourceUnreachableError(message$
|
|
5544
|
+
throw new SourceUnreachableError(message$2(error), error);
|
|
5439
5545
|
}
|
|
5440
5546
|
}
|
|
5441
5547
|
async info() {
|
|
@@ -5487,12 +5593,12 @@ var FedimintLightning = class {
|
|
|
5487
5593
|
const balance = Math.floor(await this.client().balance() / 1e3);
|
|
5488
5594
|
if (balance < amount + fee) throw new Error(`Not enough in this federation (${balance.toLocaleString()} sats; ${(amount + fee).toLocaleString()} needed)`);
|
|
5489
5595
|
} catch (error) {
|
|
5490
|
-
throw isNothingSpentError(error) ? error : new NothingSpentError(message$
|
|
5596
|
+
throw isNothingSpentError(error) ? error : new NothingSpentError(message$2(error));
|
|
5491
5597
|
}
|
|
5492
5598
|
try {
|
|
5493
5599
|
paid = await this.client().payInvoice(invoice, note?.slice(0, 64) ?? "lightning");
|
|
5494
5600
|
} catch (error) {
|
|
5495
|
-
if (BEFORE_FUNDING.test(message$
|
|
5601
|
+
if (BEFORE_FUNDING.test(message$2(error))) throw new NothingSpentError(message$2(error));
|
|
5496
5602
|
throw error;
|
|
5497
5603
|
}
|
|
5498
5604
|
const ref = JSON.stringify({
|
|
@@ -7175,7 +7281,7 @@ function fetchTransport(url, macaroon, signal) {
|
|
|
7175
7281
|
}
|
|
7176
7282
|
};
|
|
7177
7283
|
}
|
|
7178
|
-
/** Desktop: Rust makes the request (
|
|
7284
|
+
/** Desktop: Rust makes the request (apps/desktop/src/lnd.rs), pinning the certificate when there is one. */
|
|
7179
7285
|
function tauriTransport(invoke, url, macaroon, certificate) {
|
|
7180
7286
|
let closed = false;
|
|
7181
7287
|
return {
|
|
@@ -7215,11 +7321,11 @@ var describe = (error) => (error.message ?? "unknown error").slice(0, 200);
|
|
|
7215
7321
|
* found" in front of the node says nothing about the payment, and must not become "nothing was spent".
|
|
7216
7322
|
*/
|
|
7217
7323
|
var notInitiated = (error) => /payment isn't initiated/i.test(error.message ?? "");
|
|
7218
|
-
var sats$
|
|
7324
|
+
var sats$2 = (value) => {
|
|
7219
7325
|
const n = Number(value ?? 0);
|
|
7220
7326
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
7221
7327
|
};
|
|
7222
|
-
var feeOf = (p) => p.fee_msat !== void 0 ? Math.ceil(Number(p.fee_msat) / 1e3) : sats$
|
|
7328
|
+
var feeOf = (p) => p.fee_msat !== void 0 ? Math.ceil(Number(p.fee_msat) / 1e3) : sats$2(p.fee_sat);
|
|
7223
7329
|
var FAILURES = {
|
|
7224
7330
|
FAILURE_REASON_NO_ROUTE: "No route to the recipient within the fee limit",
|
|
7225
7331
|
FAILURE_REASON_INSUFFICIENT_BALANCE: "Not enough outbound liquidity in the node's channels",
|
|
@@ -7266,7 +7372,7 @@ var LndLightning = class {
|
|
|
7266
7372
|
const network = NETWORKS[chain.network ?? ""];
|
|
7267
7373
|
if (!network) throw new Error(`The node runs on ${chain.network ?? "an unknown network"}, which Ghostly does not support`);
|
|
7268
7374
|
let balance;
|
|
7269
|
-
if (this.capabilities.balance) balance = sats$
|
|
7375
|
+
if (this.capabilities.balance) balance = sats$2((await this.call("GET", "/v1/balance/channels")).local_balance?.sat);
|
|
7270
7376
|
return {
|
|
7271
7377
|
network,
|
|
7272
7378
|
alias: info.alias?.slice(0, 64) || void 0,
|
|
@@ -7297,7 +7403,7 @@ var LndLightning = class {
|
|
|
7297
7403
|
const found = await this.call("GET", `/v1/invoice/${invoice.paymentHash}`);
|
|
7298
7404
|
if (found.state === "SETTLED") return {
|
|
7299
7405
|
state: "paid",
|
|
7300
|
-
amount: sats$
|
|
7406
|
+
amount: sats$2(found.amt_paid_sat)
|
|
7301
7407
|
};
|
|
7302
7408
|
if (found.state === "CANCELED") return { state: "expired" };
|
|
7303
7409
|
return { state: (found.creation_date && found.expiry ? (Number(found.creation_date) + Number(found.expiry)) * 1e3 : invoice.expiresAt) < Date.now() ? "expired" : "open" };
|
|
@@ -7460,7 +7566,7 @@ var REFUSED = /\b(user (rejected|denied|declined|cancell?ed)|(rejected|denied|de
|
|
|
7460
7566
|
var NOT_SENT = /\b(no[ _-]?route|insufficient[ _-](local[ _-])?balance|not enough (balance|funds)|insufficient funds)\b/i;
|
|
7461
7567
|
var hex$5 = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
7462
7568
|
var bytes = (value) => new Uint8Array((value.match(/../g) ?? []).map((pair) => parseInt(pair, 16)));
|
|
7463
|
-
var message = (error) => (error instanceof Error ? error.message : typeof error === "string" ? error : "no answer").slice(0, 200);
|
|
7569
|
+
var message$1 = (error) => (error instanceof Error ? error.message : typeof error === "string" ? error : "no answer").slice(0, 200);
|
|
7464
7570
|
/** A preimage that is the one of this payment hash: proof the invoice was paid. */
|
|
7465
7571
|
var provesPayment = (preimage, paymentHash) => typeof preimage === "string" && /^[0-9a-f]{64}$/i.test(preimage) && hex$5(sha256(bytes(preimage.toLowerCase()))) === paymentHash.toLowerCase();
|
|
7466
7572
|
/** A network named by a wallet, in Ghostly's words. Anything else is no answer. */
|
|
@@ -7535,13 +7641,13 @@ var WeblnLightning = class WeblnLightning {
|
|
|
7535
7641
|
if (answer && typeof answer === "object" && answer.enabled === false) throw new Error("not enabled");
|
|
7536
7642
|
} catch (error) {
|
|
7537
7643
|
if (signal?.aborted) throw error;
|
|
7538
|
-
throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet refused the connection (${message(error)})`), { cause: error });
|
|
7644
|
+
throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet refused the connection (${message$1(error)})`), { cause: error });
|
|
7539
7645
|
}
|
|
7540
7646
|
const info = typeof webln.getInfo === "function" ? await within$1(() => webln.getInfo(), CALL_MS, "info", signal).catch(() => void 0) : void 0;
|
|
7541
7647
|
const methods = Array.isArray(info?.methods) ? info.methods : void 0;
|
|
7542
7648
|
const has = (method) => typeof webln[method] === "function" && (!methods || methods.includes(method));
|
|
7543
7649
|
let balance = false;
|
|
7544
|
-
if (has("getBalance")) balance = await within$1(() => webln.getBalance(), CALL_MS, "balance", signal).then((b) => sats$
|
|
7650
|
+
if (has("getBalance")) balance = await within$1(() => webln.getBalance(), CALL_MS, "balance", signal).then((b) => sats$1(b) !== void 0, () => false);
|
|
7545
7651
|
const capabilities = {
|
|
7546
7652
|
receive: has("makeInvoice"),
|
|
7547
7653
|
send: has("sendPayment"),
|
|
@@ -7573,7 +7679,7 @@ var WeblnLightning = class WeblnLightning {
|
|
|
7573
7679
|
return {
|
|
7574
7680
|
network: this.network,
|
|
7575
7681
|
alias: this.alias,
|
|
7576
|
-
...this.capabilities.balance ? { balance: sats$
|
|
7682
|
+
...this.capabilities.balance ? { balance: sats$1(await within$1(() => this.webln.getBalance(), CALL_MS, "balance", this.signal)) ?? 0 } : {}
|
|
7577
7683
|
};
|
|
7578
7684
|
}
|
|
7579
7685
|
async createInvoice(amount, memo) {
|
|
@@ -7616,18 +7722,18 @@ var WeblnLightning = class WeblnLightning {
|
|
|
7616
7722
|
if (!decoded?.paymentHash || decoded.amountSat === null) throw new Error("That is not an invoice this wallet can pay");
|
|
7617
7723
|
if (!sameChain(this.network, decoded.network)) throw new Error(`That invoice is for ${decoded.network}, and this browser wallet is on ${this.network}`);
|
|
7618
7724
|
if (this.capabilities.balance) {
|
|
7619
|
-
const balance = sats$
|
|
7725
|
+
const balance = sats$1(await within$1(() => this.webln.getBalance(), CALL_MS, "balance", this.signal));
|
|
7620
7726
|
if (balance !== void 0 && balance < decoded.amountSat) throw new Error(`Not enough in the browser wallet (${balance} sats)`);
|
|
7621
7727
|
}
|
|
7622
7728
|
} catch (error) {
|
|
7623
|
-
throw new NothingSpentError(message(error));
|
|
7729
|
+
throw new NothingSpentError(message$1(error));
|
|
7624
7730
|
}
|
|
7625
7731
|
let answer;
|
|
7626
7732
|
try {
|
|
7627
7733
|
answer = await within$1(() => this.webln.sendPayment(decoded.invoice), PROMPT_MS, "payment", this.signal);
|
|
7628
7734
|
} catch (error) {
|
|
7629
|
-
if (!this.signal?.aborted && error instanceof Error && (REFUSED_NAMES.has(error.name) || REFUSED.test(error.message) || NOT_SENT.test(error.message))) throw new NothingSpentError(`The browser wallet did not pay: ${message(error)}`);
|
|
7630
|
-
throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet did not say whether it paid (${message(error)})`), { cause: error });
|
|
7735
|
+
if (!this.signal?.aborted && error instanceof Error && (REFUSED_NAMES.has(error.name) || REFUSED.test(error.message) || NOT_SENT.test(error.message))) throw new NothingSpentError(`The browser wallet did not pay: ${message$1(error)}`);
|
|
7736
|
+
throw Object.assign(/* @__PURE__ */ new Error(`The browser wallet did not say whether it paid (${message$1(error)})`), { cause: error });
|
|
7631
7737
|
}
|
|
7632
7738
|
if (!provesPayment(answer?.preimage, decoded.paymentHash)) throw new Error("The browser wallet answered without proof of payment");
|
|
7633
7739
|
const fee = answer?.route?.total_fees;
|
|
@@ -7665,7 +7771,7 @@ var WeblnLightning = class WeblnLightning {
|
|
|
7665
7771
|
}
|
|
7666
7772
|
};
|
|
7667
7773
|
/** A WebLN balance in whole sats, when it is one. Wallets that answer in a fiat currency have none here. */
|
|
7668
|
-
function sats$
|
|
7774
|
+
function sats$1(balance) {
|
|
7669
7775
|
const value = Number(balance?.balance);
|
|
7670
7776
|
if (!Number.isFinite(value) || value < 0) return void 0;
|
|
7671
7777
|
const currency = typeof balance?.currency === "string" ? balance.currency.trim().toLowerCase() : "sats";
|
|
@@ -8215,6 +8321,25 @@ var IdentityProofs = class {
|
|
|
8215
8321
|
this.seeds.delete(id);
|
|
8216
8322
|
}
|
|
8217
8323
|
}
|
|
8324
|
+
/**
|
|
8325
|
+
* "Clear all data": every proof that was ever shared, and every removed one still being revoked, gets its revocation
|
|
8326
|
+
* record published once more, all at once. Nothing will republish them after the clear, so this is a last word for
|
|
8327
|
+
* contacts who check; proofs posted elsewhere (a Bluesky record, a Pubky file, a DNS record) are the person's to delete.
|
|
8328
|
+
*/
|
|
8329
|
+
async revokeAll() {
|
|
8330
|
+
this.stop();
|
|
8331
|
+
const shared = new Set(this.host.linkIds().flatMap((l) => this.host.ledger(l)?.shared.map((s) => s.id) ?? []));
|
|
8332
|
+
const at = now();
|
|
8333
|
+
const last = [...this.revocations.filter((r) => r.expiresAt > at), ...this.proofs.filter((p) => shared.has(identityStatement(p.binding).id)).map((p) => ({
|
|
8334
|
+
id: identityStatement(p.binding).id,
|
|
8335
|
+
key: p.binding.key,
|
|
8336
|
+
seed: p.seed,
|
|
8337
|
+
revokedAt: at,
|
|
8338
|
+
expiresAt: Math.min(p.binding.expiresAt, p.verified.expiresAt ?? Infinity)
|
|
8339
|
+
}))];
|
|
8340
|
+
await Promise.allSettled(last.map((r) => this.publishRevocation(r)));
|
|
8341
|
+
this.stop();
|
|
8342
|
+
}
|
|
8218
8343
|
async publishRevocation(r) {
|
|
8219
8344
|
if (!this.host.online()) return;
|
|
8220
8345
|
await this.host.publish(await this.seedOf(r.id, r.seed), [{
|
|
@@ -8559,6 +8684,18 @@ var ProfileDid = class {
|
|
|
8559
8684
|
}
|
|
8560
8685
|
this.host.emit();
|
|
8561
8686
|
}
|
|
8687
|
+
/**
|
|
8688
|
+
* "Clear all data": the DID ends. When a document was ever published, a newer packet reading `deactivated` replaces
|
|
8689
|
+
* it, so resolvers say the DID is gone instead of showing the old document until it expires. Stops publishing first.
|
|
8690
|
+
*/
|
|
8691
|
+
async deactivate() {
|
|
8692
|
+
this.stop();
|
|
8693
|
+
const previous = this.stored?.published;
|
|
8694
|
+
if (!this.stored || !previous) return;
|
|
8695
|
+
const seq = Math.max(Math.floor(Date.now() / 1e3), previous.seq + 1);
|
|
8696
|
+
const identity = identityFromSeed(fromBase64Url(await unsealSeed(this.stored.seed.sealed, this.stored.seed.deviceKey)));
|
|
8697
|
+
await this.host.publish(toZ32(this.publicKey()), signDidDhtPacket(identity, encodeDidDhtDeactivated(this.publicKey()), seq));
|
|
8698
|
+
}
|
|
8562
8699
|
listed() {
|
|
8563
8700
|
return this.stored?.listed ?? [];
|
|
8564
8701
|
}
|
|
@@ -11605,9 +11742,10 @@ var FileDesk = class {
|
|
|
11605
11742
|
await chat.saving;
|
|
11606
11743
|
const stored = await fileStore.get(id);
|
|
11607
11744
|
const bytes = stored?.bytes && await fileBytesOf(stored.bytes) || await fileBytes();
|
|
11608
|
-
const
|
|
11745
|
+
const size = await bytes.size(id), have = size ?? 0;
|
|
11609
11746
|
const offset = Math.min(have, record.confirmed);
|
|
11610
11747
|
if (have > offset) await bytes.truncate(id, offset);
|
|
11748
|
+
if (size === null && record.file.size === 0) await bytes.append(id, 0, /* @__PURE__ */ new Uint8Array());
|
|
11611
11749
|
if (stored && stored.bytes !== bytes.kind) await fileStore.patch(id, { bytes: bytes.kind });
|
|
11612
11750
|
const appender = new FileAppender(bytes, id, offset);
|
|
11613
11751
|
return {
|
|
@@ -11687,7 +11825,9 @@ var FileDesk = class {
|
|
|
11687
11825
|
});
|
|
11688
11826
|
}).catch(() => {});
|
|
11689
11827
|
}
|
|
11828
|
+
const offered = record.direction === "in" && !this.deps.transfers.has(chat.local.get(record.id));
|
|
11690
11829
|
this.show(linkId, chat, record, transferred, progress);
|
|
11830
|
+
if (offered) this.deps.flush?.();
|
|
11691
11831
|
const id = record.direction === "out" ? outgoingFileId(linkId, record.id) : chat.local.get(record.id);
|
|
11692
11832
|
const now = Date.now();
|
|
11693
11833
|
if (!progress || now - (chat.lastSaved.get(id) ?? 0) >= SAVE_EVERY_MS) {
|
|
@@ -11845,6 +11985,221 @@ var FileDesk = class {
|
|
|
11845
11985
|
};
|
|
11846
11986
|
}
|
|
11847
11987
|
};
|
|
11988
|
+
//#endregion
|
|
11989
|
+
//#region ../browser/src/shared/backupReminder.ts
|
|
11990
|
+
/**
|
|
11991
|
+
* The wallets that hold their money on this device, and their copy. Lightning is left out (through the Cashu mints it
|
|
11992
|
+
* is Cashu's balance; through a node of one's own, the node keeps it), and so is on-chain Bitcoin (an external source).
|
|
11993
|
+
*/
|
|
11994
|
+
var BACKUP_KIND = {
|
|
11995
|
+
cashu: "profile",
|
|
11996
|
+
usdt: "phrase",
|
|
11997
|
+
arkade: "phrase",
|
|
11998
|
+
bark: "phrase",
|
|
11999
|
+
spark: "phrase",
|
|
12000
|
+
fedimint: "phrase"
|
|
12001
|
+
};
|
|
12002
|
+
var asks = (w) => w.network === "mainnet" && BACKUP_KIND[w.type] !== void 0;
|
|
12003
|
+
var big = (text) => {
|
|
12004
|
+
try {
|
|
12005
|
+
return text === void 0 ? void 0 : BigInt(text);
|
|
12006
|
+
} catch {
|
|
12007
|
+
return;
|
|
12008
|
+
}
|
|
12009
|
+
};
|
|
12010
|
+
/**
|
|
12011
|
+
* The Mainnet balances the reminder watches, read from the wallets' views: only wallets that exist and say what they
|
|
12012
|
+
* hold (a locked or connecting one says nothing yet).
|
|
12013
|
+
*/
|
|
12014
|
+
function mainnetBalances(wallets, view) {
|
|
12015
|
+
if (!view) return [];
|
|
12016
|
+
const out = [];
|
|
12017
|
+
for (const w of wallets) {
|
|
12018
|
+
if (!asks(w)) continue;
|
|
12019
|
+
let balance;
|
|
12020
|
+
switch (w.type) {
|
|
12021
|
+
case "cashu":
|
|
12022
|
+
balance = BigInt(Math.max(0, Math.floor(view.balance)));
|
|
12023
|
+
break;
|
|
12024
|
+
case "usdt":
|
|
12025
|
+
balance = view.usdt?.configured && !view.usdt.locked ? big(view.usdt.balance) : void 0;
|
|
12026
|
+
break;
|
|
12027
|
+
case "arkade":
|
|
12028
|
+
balance = view.ark?.configured && !view.ark.locked ? BigInt(Math.floor(view.ark.balance ?? 0)) : void 0;
|
|
12029
|
+
break;
|
|
12030
|
+
case "bark":
|
|
12031
|
+
balance = view.bark?.configured && !view.bark.locked ? BigInt(Math.floor(view.bark.balance ?? 0)) : void 0;
|
|
12032
|
+
break;
|
|
12033
|
+
case "spark":
|
|
12034
|
+
balance = view.spark?.configured && !view.spark.locked ? BigInt(Math.floor(view.spark.balance ?? 0)) : void 0;
|
|
12035
|
+
break;
|
|
12036
|
+
case "fedimint": balance = view.fedimint?.federations.length ? BigInt(Math.floor(view.fedimint.balance ?? 0)) : void 0;
|
|
12037
|
+
}
|
|
12038
|
+
if (balance !== void 0) out.push({
|
|
12039
|
+
id: w.id,
|
|
12040
|
+
type: w.type,
|
|
12041
|
+
network: w.network,
|
|
12042
|
+
balance
|
|
12043
|
+
});
|
|
12044
|
+
}
|
|
12045
|
+
return out;
|
|
12046
|
+
}
|
|
12047
|
+
/**
|
|
12048
|
+
* What the balances now change in the records: a wallet holding money for the first time is funded; one put off with
|
|
12049
|
+
* "Later" comes back when more money came in than it held then (and after a spend, the mark follows the balance down).
|
|
12050
|
+
* `changed` is false when nothing needs saving.
|
|
12051
|
+
*/
|
|
12052
|
+
function observeBalances(records, balances, now) {
|
|
12053
|
+
let next = records, changed = false;
|
|
12054
|
+
const put = (id, record) => {
|
|
12055
|
+
next = {
|
|
12056
|
+
...next,
|
|
12057
|
+
[id]: record
|
|
12058
|
+
};
|
|
12059
|
+
changed = true;
|
|
12060
|
+
};
|
|
12061
|
+
for (const { id, balance, ...w } of balances) {
|
|
12062
|
+
if (!asks(w)) continue;
|
|
12063
|
+
const record = next[id] ?? {};
|
|
12064
|
+
if (balance > 0n && record.funded === void 0) {
|
|
12065
|
+
put(id, {
|
|
12066
|
+
...record,
|
|
12067
|
+
funded: now
|
|
12068
|
+
});
|
|
12069
|
+
continue;
|
|
12070
|
+
}
|
|
12071
|
+
const mark = big(record.laterBalance);
|
|
12072
|
+
if (record.later === void 0 || mark === void 0) continue;
|
|
12073
|
+
if (balance > mark) {
|
|
12074
|
+
const { later: _l, laterBalance: _b, ...rest } = record;
|
|
12075
|
+
put(id, rest);
|
|
12076
|
+
} else if (balance < mark) put(id, {
|
|
12077
|
+
...record,
|
|
12078
|
+
laterBalance: balance.toString()
|
|
12079
|
+
});
|
|
12080
|
+
}
|
|
12081
|
+
return {
|
|
12082
|
+
records: next,
|
|
12083
|
+
changed
|
|
12084
|
+
};
|
|
12085
|
+
}
|
|
12086
|
+
/**
|
|
12087
|
+
* A copy was made. `phrase`: one wallet's phrase shown or its backup file made (`id`), at any time: the phrase does
|
|
12088
|
+
* not change with the money. `profile`: a profile backup, which holds every wallet as it is now: it counts for the
|
|
12089
|
+
* wallets that already hold money, not for money that comes later to a wallet that had none.
|
|
12090
|
+
*/
|
|
12091
|
+
function markBackedUp(records, made, now) {
|
|
12092
|
+
if (made.kind === "phrase") return {
|
|
12093
|
+
...records,
|
|
12094
|
+
[made.id]: {
|
|
12095
|
+
...records[made.id],
|
|
12096
|
+
backedUp: now
|
|
12097
|
+
}
|
|
12098
|
+
};
|
|
12099
|
+
const next = { ...records };
|
|
12100
|
+
for (const [id, record] of Object.entries(records)) if (record.funded !== void 0 && record.backedUp === void 0) next[id] = {
|
|
12101
|
+
...record,
|
|
12102
|
+
backedUp: now
|
|
12103
|
+
};
|
|
12104
|
+
return next;
|
|
12105
|
+
}
|
|
12106
|
+
/** "Later" on a wallet's reminder: put off until the next receive or `LATER_MS`, once; the second ends it. */
|
|
12107
|
+
function putOff(records, id, balance, now) {
|
|
12108
|
+
const record = records[id] ?? {};
|
|
12109
|
+
const laters = (record.laters ?? 0) + 1;
|
|
12110
|
+
return {
|
|
12111
|
+
...records,
|
|
12112
|
+
[id]: {
|
|
12113
|
+
...record,
|
|
12114
|
+
laters,
|
|
12115
|
+
later: now,
|
|
12116
|
+
laterBalance: balance.toString()
|
|
12117
|
+
}
|
|
12118
|
+
};
|
|
12119
|
+
}
|
|
12120
|
+
/** A wallet removed: its reminder goes with it (a new wallet of the kind has a new phrase, and asks again). */
|
|
12121
|
+
function forgetWallet(records, id) {
|
|
12122
|
+
if (!(id in records)) return records;
|
|
12123
|
+
const { [id]: _gone, ...rest } = records;
|
|
12124
|
+
return rest;
|
|
12125
|
+
}
|
|
12126
|
+
//#endregion
|
|
12127
|
+
//#region ../browser/src/shared/buttons.ts
|
|
12128
|
+
/** The buttons of a message, when it has any. */
|
|
12129
|
+
function messageButtons(message) {
|
|
12130
|
+
return message.card?.kind === "buttons" ? message.card : void 0;
|
|
12131
|
+
}
|
|
12132
|
+
/**
|
|
12133
|
+
* Whether a reply's text is a button's label (or id): ignoring case and spaces at the ends, as the sender's rule
|
|
12134
|
+
* compares labels when it refuses two a typed answer could not tell apart (`buttonLabelClash`).
|
|
12135
|
+
*/
|
|
12136
|
+
var sameLabel = sameButtonText;
|
|
12137
|
+
/**
|
|
12138
|
+
* What a reply presses on the author's side, or nothing: the original is this side's own message with buttons still
|
|
12139
|
+
* open, the button is one of them (named by `b`, or, for a reply that lost it on the way (the DHT floor carries a
|
|
12140
|
+
* reply's id alone) or came from an app without buttons, a text that is a button's label or id), and the presser has
|
|
12141
|
+
* not already answered with a `once` button. A reply naming a button says its label: one whose text is something else
|
|
12142
|
+
* ("No" naming `yes`) is only a reply, so what the chat shows and what the author takes never differ. `history`: the chat's rows, the reply itself excluded or not.
|
|
12143
|
+
*/
|
|
12144
|
+
function buttonPress(reply, original, history) {
|
|
12145
|
+
if (reply.sender !== "peer" || original.sender !== "me" || !reply.replyTo) return void 0;
|
|
12146
|
+
const card = messageButtons(original);
|
|
12147
|
+
if (!card || card.closed) return void 0;
|
|
12148
|
+
const { button, inferred } = repliedButton(card, reply.text, reply.replyTo.button);
|
|
12149
|
+
if (!button) return void 0;
|
|
12150
|
+
if (answered(card, original.id, history.filter((row) => row.id !== reply.id && row.sender === "peer" && row.member === reply.member))) return void 0;
|
|
12151
|
+
return {
|
|
12152
|
+
messageId: original.id,
|
|
12153
|
+
button: button.id,
|
|
12154
|
+
label: button.label,
|
|
12155
|
+
...inferred && { inferred: true }
|
|
12156
|
+
};
|
|
12157
|
+
}
|
|
12158
|
+
/**
|
|
12159
|
+
* The button a reply to a question presses: the one `b` names when the text is its label, or, without `b`, the one
|
|
12160
|
+
* whose label or id the text is (`inferred`). The author's rule and the presser's own view both read replies so.
|
|
12161
|
+
*/
|
|
12162
|
+
function repliedButton(card, text, named) {
|
|
12163
|
+
if (named) return { button: card.buttons.find((b) => b.id === named && sameLabel(b.label, text)) };
|
|
12164
|
+
const button = card.buttons.find((b) => sameLabel(b.label, text) || sameLabel(b.id, text));
|
|
12165
|
+
return button ? {
|
|
12166
|
+
button,
|
|
12167
|
+
inferred: true
|
|
12168
|
+
} : {};
|
|
12169
|
+
}
|
|
12170
|
+
/** Whether these rows (one person's) already answered the question with a `once` button. */
|
|
12171
|
+
function answered(card, messageId, rows) {
|
|
12172
|
+
return rows.some((row) => row.press?.messageId === messageId && !!card.buttons.find((b) => b.id === row.press.button)?.once);
|
|
12173
|
+
}
|
|
12174
|
+
/**
|
|
12175
|
+
* The buttons of a message as the reader's app shows them: which one this side pressed last (from its own replies),
|
|
12176
|
+
* and whether it may press again. Closed, or answered with a `once` button: no more presses. `chosen`: the bot's
|
|
12177
|
+
* answer when it said one, else this side's own last press. A reply typed in words that is a button's label or id
|
|
12178
|
+
* counts as its press, as the author's app takes it (`buttonPress`): "yes" answering a once question answers it here.
|
|
12179
|
+
*/
|
|
12180
|
+
function buttonsState(message, ref, history) {
|
|
12181
|
+
const card = messageButtons(message);
|
|
12182
|
+
if (!card) return void 0;
|
|
12183
|
+
let mine, last = -Infinity, locked = false;
|
|
12184
|
+
for (const row of history) {
|
|
12185
|
+
if (row.sender !== "me" || !row.replyTo) continue;
|
|
12186
|
+
if (row.replyTo.messageId !== message.id && (!ref || row.replyTo.id !== ref)) continue;
|
|
12187
|
+
const { button } = repliedButton(card, row.text, row.replyTo.button);
|
|
12188
|
+
if (!button) continue;
|
|
12189
|
+
if (button.once) locked = true;
|
|
12190
|
+
if (row.timestamp >= last) {
|
|
12191
|
+
last = row.timestamp;
|
|
12192
|
+
mine = button.id;
|
|
12193
|
+
}
|
|
12194
|
+
}
|
|
12195
|
+
const open = message.sender !== "me" && !card.closed && !locked;
|
|
12196
|
+
return {
|
|
12197
|
+
card,
|
|
12198
|
+
...mine && { mine },
|
|
12199
|
+
...(card.chosen ?? mine) && { chosen: card.chosen ?? mine },
|
|
12200
|
+
open
|
|
12201
|
+
};
|
|
12202
|
+
}
|
|
11848
12203
|
/** The notice an app sends when a chat first goes live (useChat): shown as a line, never edited. */
|
|
11849
12204
|
var JOIN_NOTICE$1 = /^👋 (?:.+ )?joined$/;
|
|
11850
12205
|
/** Whether a text reads as a join notice: the chat shows it as a line and takes the contact's name from it. */
|
|
@@ -11869,12 +12224,16 @@ function takesPeerEdit(message, text) {
|
|
|
11869
12224
|
return message.sender === "peer" && !message.file && !message.paymentId && !message.event && !message.groupPay && !JOIN_NOTICE$1.test(message.text) && (text === void 0 || !JOIN_NOTICE$1.test(text));
|
|
11870
12225
|
}
|
|
11871
12226
|
/**
|
|
11872
|
-
* The message after edit `seq`, made at `at`: the new text (and the preview that came with it, or
|
|
11873
|
-
* replaces kept in the history. A text that did not change adds no
|
|
12227
|
+
* The message after edit `seq`, made at `at`: the new text (and the preview and the status card that came with it, or
|
|
12228
|
+
* none: each belongs to its version), the version it replaces kept in the history. A text that did not change adds no
|
|
12229
|
+
* version. A status card's update adds none either (WISP 4xx · Status Cards): it replaces the card in place, and its
|
|
12230
|
+
* text is only the card's fallback, so a bot's thousands of updates keep one card and no trail of versions. Buttons
|
|
12231
|
+
* (WISP 4xx · Message Buttons) are the exception: their text is the question people answer, so a new one keeps the
|
|
12232
|
+
* version it replaces, and only a change of the buttons alone (the answer marked, closed) adds none.
|
|
11874
12233
|
*/
|
|
11875
12234
|
function withEdit(message, edit) {
|
|
11876
12235
|
const before = message.edit?.history ?? [];
|
|
11877
|
-
const history = edit.text === message.text ? before : trimHistory([...before, {
|
|
12236
|
+
const history = edit.text === message.text || edit.card && edit.card.kind !== "buttons" ? before : trimHistory([...before, {
|
|
11878
12237
|
at: message.edit?.at ?? message.timestamp,
|
|
11879
12238
|
text: message.text
|
|
11880
12239
|
}]);
|
|
@@ -11884,12 +12243,13 @@ function withEdit(message, edit) {
|
|
|
11884
12243
|
history,
|
|
11885
12244
|
...edit.pending && { pending: true }
|
|
11886
12245
|
};
|
|
11887
|
-
const { preview: _old, ...rest } = message;
|
|
12246
|
+
const { preview: _old, card: _card, ...rest } = message;
|
|
11888
12247
|
return {
|
|
11889
12248
|
...rest,
|
|
11890
12249
|
text: edit.text,
|
|
11891
12250
|
edit: next,
|
|
11892
|
-
...edit.preview && { preview: edit.preview }
|
|
12251
|
+
...edit.preview && { preview: edit.preview },
|
|
12252
|
+
...edit.card && { card: edit.card }
|
|
11893
12253
|
};
|
|
11894
12254
|
}
|
|
11895
12255
|
function trimHistory(history) {
|
|
@@ -11935,6 +12295,8 @@ var Outbox = class {
|
|
|
11935
12295
|
waiting = /* @__PURE__ */ new Map();
|
|
11936
12296
|
attempts = /* @__PURE__ */ new Map();
|
|
11937
12297
|
busy = /* @__PURE__ */ new Set();
|
|
12298
|
+
/** In flight when the chat reopened (`flush({ reopened })`): sent again on the new link once that send returns. */
|
|
12299
|
+
reopenedWhileBusy = /* @__PURE__ */ new Set();
|
|
11938
12300
|
stopped = false;
|
|
11939
12301
|
resender;
|
|
11940
12302
|
policy;
|
|
@@ -11975,6 +12337,7 @@ var Outbox = class {
|
|
|
11975
12337
|
async transmit(id, { manual = false } = {}) {
|
|
11976
12338
|
if (this.stopped || this.busy.has(id) || this.receipts.has(id)) return;
|
|
11977
12339
|
this.busy.add(id);
|
|
12340
|
+
let again;
|
|
11978
12341
|
try {
|
|
11979
12342
|
let message = (await this.store.read()).find((m) => m.id === id);
|
|
11980
12343
|
if (!message?.wireId || !message.delivery || message.delivery === "delivered") return;
|
|
@@ -12013,6 +12376,11 @@ var Outbox = class {
|
|
|
12013
12376
|
await this.requeue(id, error instanceof Error ? error.message : "Sending failed");
|
|
12014
12377
|
} finally {
|
|
12015
12378
|
this.busy.delete(id);
|
|
12379
|
+
again = this.reopenedWhileBusy.delete(id);
|
|
12380
|
+
}
|
|
12381
|
+
if (again) {
|
|
12382
|
+
const message = (await this.store.read()).find((m) => m.id === id);
|
|
12383
|
+
if (message) await this.again(message);
|
|
12016
12384
|
}
|
|
12017
12385
|
}
|
|
12018
12386
|
/**
|
|
@@ -12040,16 +12408,21 @@ var Outbox = class {
|
|
|
12040
12408
|
*/
|
|
12041
12409
|
async flush({ reopened = false } = {}) {
|
|
12042
12410
|
if (this.stopped || !reopened && !this.waiting.size) return;
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12411
|
+
if (reopened) for (const id of this.busy) this.reopenedWhileBusy.add(id);
|
|
12412
|
+
const due = (await this.store.read()).filter((m) => m.sender === "me" && m.via !== "hold" && !this.busy.has(m.id) && (m.delivery === "queued" || m.delivery === "waiting" || reopened && m.delivery === "sent" && this.receipts.has(m.id))).sort((a, b) => a.timestamp - b.timestamp);
|
|
12413
|
+
for (const message of due) await this.again(message);
|
|
12414
|
+
}
|
|
12415
|
+
/** A queued or waiting message is looked at; one sent and awaiting its receipt goes again now, if the chat can carry it. */
|
|
12416
|
+
async again(message) {
|
|
12417
|
+
if (message.sender !== "me" || message.via === "hold") return;
|
|
12418
|
+
if (message.delivery === "queued" || message.delivery === "waiting") {
|
|
12419
|
+
await this.attempt(message.id);
|
|
12420
|
+
return;
|
|
12052
12421
|
}
|
|
12422
|
+
if (message.delivery !== "sent" || !this.receipts.has(message.id)) return;
|
|
12423
|
+
this.clear(message.id);
|
|
12424
|
+
if (this.resender.ready(message)) await this.transmit(message.id);
|
|
12425
|
+
else await this.requeue(message.id, "Connection closed before receipt. Delivery is unconfirmed.");
|
|
12053
12426
|
}
|
|
12054
12427
|
async disconnected() {
|
|
12055
12428
|
const messages = await this.store.read();
|
|
@@ -12064,6 +12437,7 @@ var Outbox = class {
|
|
|
12064
12437
|
}
|
|
12065
12438
|
async stop() {
|
|
12066
12439
|
this.stopped = true;
|
|
12440
|
+
this.reopenedWhileBusy.clear();
|
|
12067
12441
|
for (const wait of this.waiting.values()) if (wait.timer) clearTimeout(wait.timer);
|
|
12068
12442
|
this.waiting.clear();
|
|
12069
12443
|
await this.disconnected();
|
|
@@ -12185,7 +12559,7 @@ var EditQueue = class {
|
|
|
12185
12559
|
* goes again at once, with a fresh count of attempts.
|
|
12186
12560
|
*/
|
|
12187
12561
|
flush({ reopened = false } = {}) {
|
|
12188
|
-
if (reopened) this.
|
|
12562
|
+
if (reopened) this.reopened();
|
|
12189
12563
|
if (this.flushing) {
|
|
12190
12564
|
this.again = true;
|
|
12191
12565
|
return this.flushing;
|
|
@@ -12202,6 +12576,14 @@ var EditQueue = class {
|
|
|
12202
12576
|
})();
|
|
12203
12577
|
return this.flushing;
|
|
12204
12578
|
}
|
|
12579
|
+
/**
|
|
12580
|
+
* A new session: what the last one sent without a confirmation may go again at once, with a fresh count of attempts.
|
|
12581
|
+
* Said once, as the session opens and before anything goes on it: said again later, it would send a second time what
|
|
12582
|
+
* this session already sent (two flushes of one opening, each starting over, sent a restored question's edit twice).
|
|
12583
|
+
*/
|
|
12584
|
+
reopened() {
|
|
12585
|
+
this.sent.clear();
|
|
12586
|
+
}
|
|
12205
12587
|
/** The contact confirmed edit `seq` of my message with this wire id. */
|
|
12206
12588
|
async received(wireId, seq) {
|
|
12207
12589
|
const row = (await this.deps.read()).find((m) => m.sender === "me" && m.wireId === wireId);
|
|
@@ -12258,7 +12640,8 @@ var EditQueue = class {
|
|
|
12258
12640
|
e: edit.seq,
|
|
12259
12641
|
ts: edit.at,
|
|
12260
12642
|
m: message.text,
|
|
12261
|
-
...message.preview && { pv: message.preview }
|
|
12643
|
+
...message.preview && { pv: message.preview },
|
|
12644
|
+
...message.card && { sc: message.card }
|
|
12262
12645
|
}, message)) continue;
|
|
12263
12646
|
this.sent.set(message.id, {
|
|
12264
12647
|
seq: edit.seq,
|
|
@@ -12280,6 +12663,54 @@ var EditQueue = class {
|
|
|
12280
12663
|
}, Math.max(0, at - this.now()));
|
|
12281
12664
|
}
|
|
12282
12665
|
};
|
|
12666
|
+
/** A card's updates from someone else are applied (stored, shown) at most once per this long, per message. */
|
|
12667
|
+
var CARD_APPLY_GAP_MS = 1e3;
|
|
12668
|
+
/**
|
|
12669
|
+
* Received status card updates, paced per message (WISP 4xx · Status Cards): the first applies at once, the rest within
|
|
12670
|
+
* `CARD_APPLY_GAP_MS` wait, and only the highest edit number of them applies when the time is up. A sender that
|
|
12671
|
+
* ignores its own pace costs this side one write and one render a second per card, whatever it sends.
|
|
12672
|
+
*/
|
|
12673
|
+
var CardEditPacer = class {
|
|
12674
|
+
now;
|
|
12675
|
+
gapMs;
|
|
12676
|
+
slots = /* @__PURE__ */ new Map();
|
|
12677
|
+
constructor(now = Date.now, gapMs = CARD_APPLY_GAP_MS) {
|
|
12678
|
+
this.now = now;
|
|
12679
|
+
this.gapMs = gapMs;
|
|
12680
|
+
}
|
|
12681
|
+
/** Applies edit `e` of the message under `key` now, or later with whatever higher edit comes meanwhile. */
|
|
12682
|
+
async take(key, e, apply) {
|
|
12683
|
+
const slot = this.slots.get(key) ?? { last: 0 };
|
|
12684
|
+
this.slots.set(key, slot);
|
|
12685
|
+
const due = slot.last + this.gapMs - this.now();
|
|
12686
|
+
if (due <= 0 && !slot.timer) {
|
|
12687
|
+
slot.last = this.now();
|
|
12688
|
+
await apply();
|
|
12689
|
+
return;
|
|
12690
|
+
}
|
|
12691
|
+
if (slot.e !== void 0 && slot.e >= e) return;
|
|
12692
|
+
slot.e = e;
|
|
12693
|
+
slot.apply = apply;
|
|
12694
|
+
slot.timer ??= setTimeout(() => {
|
|
12695
|
+
const run = slot.apply;
|
|
12696
|
+
slot.timer = void 0;
|
|
12697
|
+
slot.apply = void 0;
|
|
12698
|
+
slot.e = void 0;
|
|
12699
|
+
slot.last = this.now();
|
|
12700
|
+
run?.().catch(() => {});
|
|
12701
|
+
}, Math.max(0, due));
|
|
12702
|
+
if (this.slots.size > 512) {
|
|
12703
|
+
for (const [k, s] of this.slots) if (!s.timer) {
|
|
12704
|
+
this.slots.delete(k);
|
|
12705
|
+
break;
|
|
12706
|
+
}
|
|
12707
|
+
}
|
|
12708
|
+
}
|
|
12709
|
+
stop() {
|
|
12710
|
+
for (const slot of this.slots.values()) clearTimeout(slot.timer);
|
|
12711
|
+
this.slots.clear();
|
|
12712
|
+
}
|
|
12713
|
+
};
|
|
12283
12714
|
/**
|
|
12284
12715
|
* The contact's edits of messages not here yet (an edit that overtook its message on another path), per chat: kept
|
|
12285
12716
|
* `EDIT_BUFFER_MS` at most, the highest number per message, `EDIT_BUFFER_MAX` messages at most.
|
|
@@ -12322,13 +12753,6 @@ var UNFINISHED = /* @__PURE__ */ new Set([
|
|
|
12322
12753
|
"submitted",
|
|
12323
12754
|
"unknown"
|
|
12324
12755
|
]);
|
|
12325
|
-
var sats$1 = (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`;
|
|
12326
|
-
var known = (amount, network) => ({
|
|
12327
|
-
empty: amount <= 0,
|
|
12328
|
-
text: sats$1(Math.max(0, amount), network)
|
|
12329
|
-
});
|
|
12330
|
-
/** A self-custodial source keeps its recovery phrase sealed with it (Breez, a BDK wallet): the money is on this device. */
|
|
12331
|
-
var phraseHeld = (secrets) => !!secrets?.includes("mnemonic");
|
|
12332
12756
|
var ITEM = {
|
|
12333
12757
|
request: (a) => `A request for ${a} in a chat, still open`,
|
|
12334
12758
|
invoice: (a) => `An invoice for ${a}, not paid yet`,
|
|
@@ -12336,12 +12760,25 @@ var ITEM = {
|
|
|
12336
12760
|
unclaimed: (a) => `${a} the mint says it issued for an invoice, never received here`,
|
|
12337
12761
|
sent: (a) => `${a} in ecash you sent, not taken yet`
|
|
12338
12762
|
};
|
|
12339
|
-
|
|
12340
|
-
|
|
12763
|
+
var ENGLISH_REMOVAL = {
|
|
12764
|
+
sats: (n, network) => `${n.toLocaleString("en-US")} ${network === "testnet" ? "test sats" : "sats"}`,
|
|
12765
|
+
token: (units, decimals, network) => `${formatPaymentAmount(units, decimals)} ${network === "testnet" ? "TEST-USDT" : "USDT"}`,
|
|
12766
|
+
gas: (wei, network) => `${formatPaymentAmount(wei, 18)} ${network === "testnet" ? "test ETH" : "ETH"}`,
|
|
12767
|
+
item: (kind, amount) => ITEM[kind](amount),
|
|
12768
|
+
and: (first, second) => `${first} and ${second}`
|
|
12769
|
+
};
|
|
12770
|
+
var knownIn = (amount, network, words) => ({
|
|
12771
|
+
empty: amount <= 0,
|
|
12772
|
+
text: words.sats(Math.max(0, amount), network)
|
|
12773
|
+
});
|
|
12774
|
+
/** A self-custodial source keeps its recovery phrase sealed with it (Breez, a BDK wallet): the money is on this device. */
|
|
12775
|
+
var phraseHeld = (secrets) => !!secrets?.includes("mnemonic");
|
|
12776
|
+
function items(type, network, view, words, card) {
|
|
12777
|
+
const amount = (n) => type === "usdt" ? words.token(String(n), view?.usdt?.decimals ?? 6, network) : words.sats(n, network);
|
|
12341
12778
|
const mine = (view?.awaiting ?? []).filter((a) => a.type === type && (card === void 0 || a.card === void 0 || a.card === card));
|
|
12342
12779
|
const item = (a) => ({
|
|
12343
12780
|
kind: a.kind,
|
|
12344
|
-
text:
|
|
12781
|
+
text: words.item(a.kind, amount(a.amount)),
|
|
12345
12782
|
amount: amount(a.amount),
|
|
12346
12783
|
...a.paymentId ? { paymentId: a.paymentId } : {}
|
|
12347
12784
|
});
|
|
@@ -12355,12 +12792,13 @@ function items(type, network, view, card) {
|
|
|
12355
12792
|
* What removing the `type` wallet of `network` takes away. `intents`: the profile's payments (the wallet view's
|
|
12356
12793
|
* `intents`). `card`: the Lightning card (absent: the network's default for receiving).
|
|
12357
12794
|
*/
|
|
12358
|
-
function walletRemoval(type, network, view, intents = [], card) {
|
|
12795
|
+
function walletRemoval(type, network, view, intents = [], card, words = ENGLISH_REMOVAL) {
|
|
12796
|
+
const known = (amount) => knownIn(amount, network, words);
|
|
12359
12797
|
const lnCard = type === "lightning" ? view?.lightnings?.find((c) => card === void 0 ? c.receive : c.card === card) : void 0;
|
|
12360
12798
|
const base = {
|
|
12361
12799
|
type,
|
|
12362
12800
|
network,
|
|
12363
|
-
...items(type, network, view, lnCard?.card ?? card),
|
|
12801
|
+
...items(type, network, view, words, lnCard?.card ?? card),
|
|
12364
12802
|
...lnCard ? { card: lnCard.card } : card !== void 0 ? { card } : {}
|
|
12365
12803
|
};
|
|
12366
12804
|
const pending = intents.filter((i) => i.method === type && UNFINISHED.has(i.state) && walletNetworkOf(i.network) === network).length;
|
|
@@ -12368,7 +12806,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12368
12806
|
case "cashu": return {
|
|
12369
12807
|
...base,
|
|
12370
12808
|
custody: "device",
|
|
12371
|
-
held: known(view?.balance ?? 0
|
|
12809
|
+
held: known(view?.balance ?? 0),
|
|
12372
12810
|
backup: "tokens",
|
|
12373
12811
|
pending
|
|
12374
12812
|
};
|
|
@@ -12378,7 +12816,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12378
12816
|
if (!ln?.providerId || ln.providerId === "cashu-mint") return {
|
|
12379
12817
|
...base,
|
|
12380
12818
|
custody: "elsewhere",
|
|
12381
|
-
held: known(0
|
|
12819
|
+
held: known(0),
|
|
12382
12820
|
backup: "none",
|
|
12383
12821
|
pending: 0,
|
|
12384
12822
|
...others ? {} : { comesWith: "cashu" },
|
|
@@ -12389,7 +12827,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12389
12827
|
return {
|
|
12390
12828
|
...base,
|
|
12391
12829
|
custody: device ? "device" : "elsewhere",
|
|
12392
|
-
held: ln.status === "ready" && ln.balance !== void 0 ? known(ln.balance
|
|
12830
|
+
held: ln.status === "ready" && ln.balance !== void 0 ? known(ln.balance) : device ? "unknown" : known(0),
|
|
12393
12831
|
backup: "none",
|
|
12394
12832
|
pending
|
|
12395
12833
|
};
|
|
@@ -12400,7 +12838,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12400
12838
|
return {
|
|
12401
12839
|
...base,
|
|
12402
12840
|
custody: device ? "device" : "elsewhere",
|
|
12403
|
-
held: bt?.status === "ready" ? known(total
|
|
12841
|
+
held: bt?.status === "ready" ? known(total) : device ? "unknown" : known(0),
|
|
12404
12842
|
backup: "none",
|
|
12405
12843
|
pending
|
|
12406
12844
|
};
|
|
@@ -12410,7 +12848,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12410
12848
|
return {
|
|
12411
12849
|
...base,
|
|
12412
12850
|
custody: "device",
|
|
12413
|
-
held: open ? known(ark.balance + (ark.recoverable ?? 0) + (ark.
|
|
12851
|
+
held: open ? known(ark.balance + (ark.recoverable ?? 0) + (ark.sweeping ?? 0) + (ark.small ?? 0) + (ark.incoming ?? 0)) : "unknown",
|
|
12414
12852
|
backup: "phrase",
|
|
12415
12853
|
pending
|
|
12416
12854
|
};
|
|
@@ -12420,7 +12858,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12420
12858
|
return {
|
|
12421
12859
|
...base,
|
|
12422
12860
|
custody: "device",
|
|
12423
|
-
held: open ? known(bark.balance
|
|
12861
|
+
held: open ? known(bark.balance) : "unknown",
|
|
12424
12862
|
backup: "phrase",
|
|
12425
12863
|
pending
|
|
12426
12864
|
};
|
|
@@ -12430,7 +12868,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12430
12868
|
return {
|
|
12431
12869
|
...base,
|
|
12432
12870
|
custody: "device",
|
|
12433
|
-
held: open ? known(spark.balance
|
|
12871
|
+
held: open ? known(spark.balance) : "unknown",
|
|
12434
12872
|
backup: "phrase",
|
|
12435
12873
|
pending
|
|
12436
12874
|
};
|
|
@@ -12441,7 +12879,7 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12441
12879
|
return {
|
|
12442
12880
|
...base,
|
|
12443
12881
|
custody: "device",
|
|
12444
|
-
held: open ? known(fm?.balance ?? 0
|
|
12882
|
+
held: open ? known(fm?.balance ?? 0) : "unknown",
|
|
12445
12883
|
backup: "phrase",
|
|
12446
12884
|
pending
|
|
12447
12885
|
};
|
|
@@ -12456,14 +12894,14 @@ function walletRemoval(type, network, view, intents = [], card) {
|
|
|
12456
12894
|
pending
|
|
12457
12895
|
};
|
|
12458
12896
|
const token = BigInt(usdt.balance || "0"), gas = BigInt(usdt.gasBalance || "0");
|
|
12459
|
-
const
|
|
12460
|
-
const
|
|
12897
|
+
const held = token > 0n || gas === 0n ? words.token(usdt.balance || "0", usdt.decimals ?? 6, network) : void 0;
|
|
12898
|
+
const fuel = gas > 0n ? words.gas(usdt.gasBalance, network) : void 0;
|
|
12461
12899
|
return {
|
|
12462
12900
|
...base,
|
|
12463
12901
|
custody: "device",
|
|
12464
12902
|
held: {
|
|
12465
12903
|
empty: token === 0n && gas === 0n,
|
|
12466
|
-
text:
|
|
12904
|
+
text: held && fuel ? words.and(held, fuel) : held ?? fuel
|
|
12467
12905
|
},
|
|
12468
12906
|
backup: "phrase",
|
|
12469
12907
|
pending
|
|
@@ -12565,9 +13003,13 @@ function walletAwaiting({ network, mints, quotes, lightningOps, lightningSource,
|
|
|
12565
13003
|
...type === "lightning" ? onCard(cardOf.get(p.id)) : {}
|
|
12566
13004
|
});
|
|
12567
13005
|
}
|
|
13006
|
+
const paid = payments.filter((p) => p.kind === "request" && p.direction === "out" && p.state === "settled");
|
|
13007
|
+
const paidIds = new Set(paid.map((p) => p.id));
|
|
13008
|
+
const paidInvoices = new Set(paid.filter((p) => p.invoice).map((p) => invoiceKey(p.invoice)));
|
|
13009
|
+
const answered = (x, owner) => owner !== void 0 && paidIds.has(owner) || paidInvoices.has(invoiceKey(x.invoice));
|
|
12568
13010
|
for (const q of openQuotes) {
|
|
12569
13011
|
const owner = ownerOf(q);
|
|
12570
|
-
if (!owner || !requests.has(owner)) out.push({
|
|
13012
|
+
if ((!owner || !requests.has(owner)) && !answered(q, owner)) out.push({
|
|
12571
13013
|
type: "cashu",
|
|
12572
13014
|
kind: "invoice",
|
|
12573
13015
|
amount: q.amount,
|
|
@@ -12576,7 +13018,7 @@ function walletAwaiting({ network, mints, quotes, lightningOps, lightningSource,
|
|
|
12576
13018
|
}
|
|
12577
13019
|
for (const op of openOps) {
|
|
12578
13020
|
const owner = ownerOf(op);
|
|
12579
|
-
if (!owner || !requests.has(owner)) out.push({
|
|
13021
|
+
if ((!owner || !requests.has(owner)) && !answered(op, owner)) out.push({
|
|
12580
13022
|
type: "lightning",
|
|
12581
13023
|
kind: "invoice",
|
|
12582
13024
|
amount: op.amount,
|
|
@@ -12605,6 +13047,23 @@ function walletAwaiting({ network, mints, quotes, lightningOps, lightningSource,
|
|
|
12605
13047
|
}
|
|
12606
13048
|
return out;
|
|
12607
13049
|
}
|
|
13050
|
+
/**
|
|
13051
|
+
* What one Cashu mint still waits for: its invoices not paid yet, paid ones whose ecash is not claimed, and requests
|
|
13052
|
+
* of ours whose invoice is one of them. A request other mints or rails can still be paid through is not counted, nor
|
|
13053
|
+
* ecash sent from it (adding the mint again takes that back).
|
|
13054
|
+
*/
|
|
13055
|
+
function mintAwaiting(mint, sources) {
|
|
13056
|
+
const key = (invoice) => invoice.trim().toLowerCase();
|
|
13057
|
+
const invoices = new Set(sources.quotes.filter((q) => q.mint === mint).map((q) => key(q.invoice)));
|
|
13058
|
+
const invoiceOf = new Map(sources.payments.map((p) => [p.id, p.invoice]));
|
|
13059
|
+
const ours = (a) => a.kind !== "request" || !!a.paymentId && invoices.has(key(invoiceOf.get(a.paymentId) ?? ""));
|
|
13060
|
+
return walletAwaiting({
|
|
13061
|
+
...sources,
|
|
13062
|
+
mints: [mint],
|
|
13063
|
+
lightningOps: [],
|
|
13064
|
+
lightningSource: "none"
|
|
13065
|
+
}).filter((a) => a.type === "cashu" && a.kind !== "sent" && ours(a));
|
|
13066
|
+
}
|
|
12608
13067
|
//#endregion
|
|
12609
13068
|
//#region ../browser/src/engine/paymentAdapters/testCoins.ts
|
|
12610
13069
|
/**
|
|
@@ -12940,7 +13399,16 @@ function note(message, by, emoji, at) {
|
|
|
12940
13399
|
by,
|
|
12941
13400
|
emoji,
|
|
12942
13401
|
snippet: replySnippet(message.text),
|
|
12943
|
-
mine: message.sender === "me"
|
|
13402
|
+
mine: message.sender === "me",
|
|
13403
|
+
message: message.id
|
|
13404
|
+
};
|
|
13405
|
+
}
|
|
13406
|
+
/** The note again after its message changed (an edit): its line is the message's text now. Undefined when nothing changed. */
|
|
13407
|
+
function noteAfterChange(current, message) {
|
|
13408
|
+
const snippet = replySnippet(message.text);
|
|
13409
|
+
return snippet === current.snippet ? void 0 : {
|
|
13410
|
+
...current,
|
|
13411
|
+
snippet
|
|
12944
13412
|
};
|
|
12945
13413
|
}
|
|
12946
13414
|
/**
|
|
@@ -13230,6 +13698,12 @@ var BEACON_RETRY_MS = 5e3;
|
|
|
13230
13698
|
/** An admission's links keep looking fast (see `keepLooking`), renewed this often, an edge for this long. */
|
|
13231
13699
|
var REARM_MS = 2e4;
|
|
13232
13700
|
var AWAIT_EDGE_MS = 12e4;
|
|
13701
|
+
/**
|
|
13702
|
+
* After a restart, the edges of the last run stay open this long, whatever the topology picks at first: the members
|
|
13703
|
+
* and hubs at their other ends watch for this app to come back (a member keeps a hub that went away `hubWaitMs`, a
|
|
13704
|
+
* hub its members a minute, another hub three), and the app dials those that were live once at start (`resume`).
|
|
13705
|
+
*/
|
|
13706
|
+
var RESTORED_EDGE_MS = 6e4;
|
|
13233
13707
|
/** After letting someone in, the door keeps its reads at the admitting pace this long. */
|
|
13234
13708
|
var ADMITTED_QUIET_MS = 3e4;
|
|
13235
13709
|
var MAX_PENDING_ENTRIES$1 = 8;
|
|
@@ -13270,6 +13744,8 @@ var Communities = class {
|
|
|
13270
13744
|
stored = /* @__PURE__ */ new Map();
|
|
13271
13745
|
live = /* @__PURE__ */ new Map();
|
|
13272
13746
|
lastMessageAt = /* @__PURE__ */ new Map();
|
|
13747
|
+
/** The latest message from another member, per group: what makes it unread in the list. */
|
|
13748
|
+
lastPeerMessageAt = /* @__PURE__ */ new Map();
|
|
13273
13749
|
lastMentionAt = /* @__PURE__ */ new Map();
|
|
13274
13750
|
refused = /* @__PURE__ */ new Map();
|
|
13275
13751
|
lastKnock = /* @__PURE__ */ new Map();
|
|
@@ -13317,11 +13793,15 @@ var Communities = class {
|
|
|
13317
13793
|
for (const group of groups) {
|
|
13318
13794
|
this.stored.set(group.id, group);
|
|
13319
13795
|
if (group.community) this.attach(group.community);
|
|
13320
|
-
const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)),
|
|
13796
|
+
const history = await this.store.getMessages(MESSAGE_LINK$1(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
|
|
13797
|
+
const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
|
|
13321
13798
|
if (last) this.lastMessageAt.set(group.id, last.timestamp);
|
|
13799
|
+
if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
|
|
13322
13800
|
const mention = [...history].reverse().find((m) => m.mentioned);
|
|
13323
13801
|
if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
|
|
13324
13802
|
for (const [, linkId] of this.host.entries(group.id)) if (group.joining?.linkId !== linkId) await this.host.closeEdge(linkId);
|
|
13803
|
+
const live = this.live.get(group.id);
|
|
13804
|
+
if (live) for (const key of this.host.edges(group.id).keys()) live.restored.set(key, 0);
|
|
13325
13805
|
}
|
|
13326
13806
|
}
|
|
13327
13807
|
/** A community as kept here, to change in place and save (`Groups.setPin`). */
|
|
@@ -13336,6 +13816,7 @@ var Communities = class {
|
|
|
13336
13816
|
profile: "community",
|
|
13337
13817
|
createdAt: group.createdAt,
|
|
13338
13818
|
lastMessageAt: this.lastMessageAt.get(group.id) ?? 0,
|
|
13819
|
+
...peerMessageAt(this.lastPeerMessageAt, group.id),
|
|
13339
13820
|
...mentionAt(this.lastMentionAt, group.id),
|
|
13340
13821
|
invited: [],
|
|
13341
13822
|
memberLinks: {}
|
|
@@ -13454,10 +13935,10 @@ var Communities = class {
|
|
|
13454
13935
|
traceJoin(link.g, "join.start");
|
|
13455
13936
|
this.knock(group, since).catch(() => {});
|
|
13456
13937
|
}
|
|
13457
|
-
async send(groupId, text, mentions = [], reply, forwarded) {
|
|
13938
|
+
async send(groupId, text, mentions = [], reply, forwarded, card) {
|
|
13458
13939
|
const live = this.live.get(groupId);
|
|
13459
13940
|
if (!live) return { error: "You are not in this group yet" };
|
|
13460
|
-
const result = await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded);
|
|
13941
|
+
const result = card ? await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded, card) : await live.session.sendText(text, this.host.myNick?.(), Date.now(), mentions, reply, forwarded);
|
|
13461
13942
|
return "error" in result ? { error: result.error } : {
|
|
13462
13943
|
error: null,
|
|
13463
13944
|
messageId: result.id
|
|
@@ -13479,7 +13960,8 @@ var Communities = class {
|
|
|
13479
13960
|
v: edit.e,
|
|
13480
13961
|
ts: edit.ts,
|
|
13481
13962
|
text: edit.m,
|
|
13482
|
-
mentions: edit.k
|
|
13963
|
+
mentions: edit.k,
|
|
13964
|
+
...edit.sc && { card: edit.sc }
|
|
13483
13965
|
}, this.host.myNick?.());
|
|
13484
13966
|
if ("error" in result) return result.error;
|
|
13485
13967
|
const key = editKey(edit.id, edit.e);
|
|
@@ -13578,9 +14060,11 @@ var Communities = class {
|
|
|
13578
14060
|
this.live.delete(groupId);
|
|
13579
14061
|
this.stored.delete(groupId);
|
|
13580
14062
|
this.lastMessageAt.delete(groupId);
|
|
14063
|
+
this.lastPeerMessageAt.delete(groupId);
|
|
13581
14064
|
this.lastMentionAt.delete(groupId);
|
|
13582
14065
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
13583
14066
|
await this.store.deleteGroup(groupId);
|
|
14067
|
+
this.host.historyGone?.(groupId);
|
|
13584
14068
|
this.host.emit();
|
|
13585
14069
|
}
|
|
13586
14070
|
require(groupId) {
|
|
@@ -13644,7 +14128,7 @@ var Communities = class {
|
|
|
13644
14128
|
live.hubSince = now;
|
|
13645
14129
|
live.emptySince = now;
|
|
13646
14130
|
live.knocksScanned = false;
|
|
13647
|
-
await this.publishBeacon(groupId, live, now, true);
|
|
14131
|
+
await this.publishBeacon(groupId, live, now, true).catch(() => {});
|
|
13648
14132
|
traceJoin(groupId, "hub.elected");
|
|
13649
14133
|
}
|
|
13650
14134
|
}
|
|
@@ -13658,7 +14142,7 @@ var Communities = class {
|
|
|
13658
14142
|
live.hub = false;
|
|
13659
14143
|
await this.closeEntries(groupId);
|
|
13660
14144
|
await this.publishBeacon(groupId, live, now, false);
|
|
13661
|
-
} 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);
|
|
14145
|
+
} 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(() => {});
|
|
13662
14146
|
}
|
|
13663
14147
|
if (live.hub) {
|
|
13664
14148
|
const busy = this.admitting(groupId, live, now);
|
|
@@ -13680,16 +14164,13 @@ var Communities = class {
|
|
|
13680
14164
|
live.hubsUp.add(key);
|
|
13681
14165
|
continue;
|
|
13682
14166
|
}
|
|
13683
|
-
if (live.hubsUp.delete(key)) {
|
|
13684
|
-
live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
|
|
13685
|
-
live.hubWaits.delete(key);
|
|
13686
|
-
continue;
|
|
13687
|
-
}
|
|
13688
14167
|
const since = live.hubWaits.get(key) ?? now;
|
|
13689
14168
|
live.hubWaits.set(key, since);
|
|
13690
|
-
|
|
14169
|
+
const back = live.hubsUp.has(key) && !!id && !!this.host.linkBack?.(id);
|
|
14170
|
+
if (now - since > (back ? 3 : 1) * this.timings.hubWaitMs) {
|
|
13691
14171
|
live.hubsAvoided.set(key, now + 2 * this.timings.hubWaitMs);
|
|
13692
14172
|
live.hubWaits.delete(key);
|
|
14173
|
+
live.hubsUp.delete(key);
|
|
13693
14174
|
}
|
|
13694
14175
|
}
|
|
13695
14176
|
let kept = live.myHubs.filter((key) => (fresh.has(key) || this.recentHub(live, key)) && !live.hubsAvoided.has(key));
|
|
@@ -13877,6 +14358,11 @@ var Communities = class {
|
|
|
13877
14358
|
} else for (const key of live.myHubs) wanted.add(key);
|
|
13878
14359
|
for (const [key, id] of existing) if (this.host.linkReady(id, 2) && (live.members.has(key) || this.recentHub(live, key))) wanted.add(key);
|
|
13879
14360
|
}
|
|
14361
|
+
for (const [key, since] of live.restored) {
|
|
14362
|
+
if (!since) live.restored.set(key, now);
|
|
14363
|
+
if (!existing.has(key) || now - (since || now) >= RESTORED_EDGE_MS || s.wasRemoved(key)) live.restored.delete(key);
|
|
14364
|
+
else wanted.add(key);
|
|
14365
|
+
}
|
|
13880
14366
|
for (const [key, id] of existing) if (!wanted.has(key)) await this.host.closeEdge(id);
|
|
13881
14367
|
for (const key of wanted) if (!existing.has(key) && key !== s.myKey) {
|
|
13882
14368
|
const expect = live.expect.delete(key);
|
|
@@ -13925,7 +14411,7 @@ var Communities = class {
|
|
|
13925
14411
|
g: groupId,
|
|
13926
14412
|
host: s.entryKey
|
|
13927
14413
|
};
|
|
13928
|
-
const hubs = doorHubs(live.beacon, now);
|
|
14414
|
+
const hubs = doorHubs(live.beacon, now, s.myKey);
|
|
13929
14415
|
if (!hubs.includes(s.myKey)) return;
|
|
13930
14416
|
const door = [...hubs].sort()[0], doorSig = [...hubs].sort().join(",");
|
|
13931
14417
|
if (live.doors !== doorSig) {
|
|
@@ -13963,7 +14449,7 @@ var Communities = class {
|
|
|
13963
14449
|
});
|
|
13964
14450
|
if (live.knocksSeen.get(key).changed && now - ts > 15e3) continue;
|
|
13965
14451
|
const waited = now - first, order = [door, ...rankHubs(key, hubs.filter((k) => k !== door))];
|
|
13966
|
-
const turn = Math.floor(waited / KNOCK_SLOT_MS) % order.length, early = waited % KNOCK_SLOT_MS < KNOCK_SLOT_OPEN_MS;
|
|
14452
|
+
const turn = Math.floor(waited / KNOCK_SLOT_MS) % order.length, early = order.length === 1 || waited % KNOCK_SLOT_MS < KNOCK_SLOT_OPEN_MS;
|
|
13967
14453
|
if (order[turn] !== s.myKey || !early || waited >= KNOCK_SLOT_MS && !stillKnocking) continue;
|
|
13968
14454
|
if (live.pendingEntries.size >= MAX_PENDING_ENTRIES$1) break;
|
|
13969
14455
|
if (live.members.size >= this.capacity(groupId, live, now)) break;
|
|
@@ -14000,7 +14486,7 @@ var Communities = class {
|
|
|
14000
14486
|
const joining = group.joining;
|
|
14001
14487
|
const first = !this.lastKnock.has(group.id);
|
|
14002
14488
|
this.lastKnock.set(group.id, now);
|
|
14003
|
-
if (
|
|
14489
|
+
if (first || now - joining.since < this.timings.patienceMs) this.host.expectPeer?.(joining.linkId);
|
|
14004
14490
|
const me = identityFromSeedB64(joining.seedB64).pubKeyZ32, link = {
|
|
14005
14491
|
g: group.id,
|
|
14006
14492
|
host: joining.host
|
|
@@ -14241,8 +14727,7 @@ var Communities = class {
|
|
|
14241
14727
|
message: async (m) => {
|
|
14242
14728
|
const timestamp = receivedTimestamp(m.timestamp);
|
|
14243
14729
|
const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
|
|
14244
|
-
|
|
14245
|
-
await this.host.storeMessage({
|
|
14730
|
+
const stored = await this.host.storeMessage({
|
|
14246
14731
|
linkId: MESSAGE_LINK$1(id),
|
|
14247
14732
|
id: m.id,
|
|
14248
14733
|
text: m.text,
|
|
@@ -14252,9 +14737,13 @@ var Communities = class {
|
|
|
14252
14737
|
via: "datalink",
|
|
14253
14738
|
...mentionFields(m.mentions, mentioned),
|
|
14254
14739
|
...m.reply && { replyTo: groupReply(m.reply, session.myKey) },
|
|
14255
|
-
...m.forwarded && { forwarded: m.forwarded }
|
|
14740
|
+
...m.forwarded && { forwarded: m.forwarded },
|
|
14741
|
+
...m.card && { card: m.card }
|
|
14256
14742
|
});
|
|
14257
14743
|
this.lastMessageAt.set(id, Math.max(this.lastMessageAt.get(id) ?? 0, timestamp));
|
|
14744
|
+
const came = cameAt(timestamp, stored, this.now());
|
|
14745
|
+
if (mentioned) this.lastMentionAt.set(id, Math.max(this.lastMentionAt.get(id) ?? 0, came));
|
|
14746
|
+
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(id, Math.max(this.lastPeerMessageAt.get(id) ?? 0, came));
|
|
14258
14747
|
},
|
|
14259
14748
|
app: (m) => this.deliver(id, () => this.host.communityApp?.(id, m.sender, m.frame)),
|
|
14260
14749
|
pair: (m) => this.deliver(id, () => this.host.communityPair?.(id, m.sender, m.payload)),
|
|
@@ -14296,6 +14785,7 @@ var Communities = class {
|
|
|
14296
14785
|
hubsUp: /* @__PURE__ */ new Set(),
|
|
14297
14786
|
lingering: /* @__PURE__ */ new Map(),
|
|
14298
14787
|
seenHubs: /* @__PURE__ */ new Map(),
|
|
14788
|
+
restored: /* @__PURE__ */ new Map(),
|
|
14299
14789
|
knocksScanned: false,
|
|
14300
14790
|
knockCursor: 0,
|
|
14301
14791
|
lastShardPoll: 0,
|
|
@@ -14952,6 +15442,16 @@ var GroupTypings = class {
|
|
|
14952
15442
|
};
|
|
14953
15443
|
//#endregion
|
|
14954
15444
|
//#region ../browser/src/engine/groups.ts
|
|
15445
|
+
/** What the engine gives the groups: its links, its storage and its state emitter. */
|
|
15446
|
+
/**
|
|
15447
|
+
* `GroupsHost.linkSeen`: a connection with the other end is under way, or its packet is at most `EXPECT_PEER_MS` old
|
|
15448
|
+
* (its offer follows it). Its presence alone lasts `PRESENCE_WINDOW`, 10 minutes: a hub closes an entry session it gave
|
|
15449
|
+
* up on without a last packet, and a joiner back after a restart took that packet for a member answering, stopped
|
|
15450
|
+
* knocking, and stayed "invited" for 10 minutes (2026-09-30).
|
|
15451
|
+
*/
|
|
15452
|
+
function otherEndSeen(presence, dataLink, now = Date.now()) {
|
|
15453
|
+
return !!dataLink && dataLink !== "idle" || !!presence?.online && now - presence.lastPacketAt < 3e4;
|
|
15454
|
+
}
|
|
14955
15455
|
/** The key an edit of message `id` goes by in `FramesTaken`. */
|
|
14956
15456
|
var editKey = (id, e) => `${id}#e${e}`;
|
|
14957
15457
|
/**
|
|
@@ -14980,6 +15480,18 @@ var mentionAt = (map, groupId) => {
|
|
|
14980
15480
|
const at = map.get(groupId);
|
|
14981
15481
|
return at ? { lastMentionAt: at } : {};
|
|
14982
15482
|
};
|
|
15483
|
+
/**
|
|
15484
|
+
* When a member's message counts as having come, for unread and the "@": when it reached me, if that is later than
|
|
15485
|
+
* when it was written (a catch-up from another member, a member back after a while). Written before I last looked at
|
|
15486
|
+
* the group but handed to me since, it is news here; by its own time it sorted among what I had read, and the group
|
|
15487
|
+
* never showed unread. A copy I had already (`stored` false) or one completed in place keeps its own time.
|
|
15488
|
+
*/
|
|
15489
|
+
var cameAt = (timestamp, stored, now) => stored === false ? timestamp : Math.max(timestamp, now);
|
|
15490
|
+
/** The view's `lastPeerMessageAt`, when there is one. */
|
|
15491
|
+
var peerMessageAt = (map, groupId) => {
|
|
15492
|
+
const at = map.get(groupId);
|
|
15493
|
+
return at ? { lastPeerMessageAt: at } : {};
|
|
15494
|
+
};
|
|
14983
15495
|
var ENTRY_TIMINGS = {
|
|
14984
15496
|
pollMs: 5e3,
|
|
14985
15497
|
warmPollMs: 2e3,
|
|
@@ -15067,6 +15579,8 @@ var Groups = class {
|
|
|
15067
15579
|
/** Each group's status and newest commit as its history last said them: a line per change of either, never one again. */
|
|
15068
15580
|
lastTold = /* @__PURE__ */ new Map();
|
|
15069
15581
|
lastMessageAt = /* @__PURE__ */ new Map();
|
|
15582
|
+
/** The latest message from another member, per group: what makes it unread in the list. */
|
|
15583
|
+
lastPeerMessageAt = /* @__PURE__ */ new Map();
|
|
15070
15584
|
/** The latest message that names me, per group (the chat list's "@" while it is unread). */
|
|
15071
15585
|
lastMentionAt = /* @__PURE__ */ new Map();
|
|
15072
15586
|
reconciling = Promise.resolve();
|
|
@@ -15134,8 +15648,10 @@ var Groups = class {
|
|
|
15134
15648
|
for (const group of all.filter((g) => !g.community && !g.joining)) {
|
|
15135
15649
|
this.stored.set(group.id, group);
|
|
15136
15650
|
if (group.state) this.attach(group.state);
|
|
15137
|
-
const history = await this.store.getMessages(MESSAGE_LINK(group.id)),
|
|
15651
|
+
const history = await this.store.getMessages(MESSAGE_LINK(group.id)), said = history.filter((m) => !m.event && !m.groupPay);
|
|
15652
|
+
const last = said[said.length - 1], lastPeer = [...said].reverse().find((m) => m.sender !== "me");
|
|
15138
15653
|
if (last) this.lastMessageAt.set(group.id, last.timestamp);
|
|
15654
|
+
if (lastPeer) this.lastPeerMessageAt.set(group.id, lastPeer.timestamp);
|
|
15139
15655
|
const mention = [...history].reverse().find((m) => m.mentioned);
|
|
15140
15656
|
if (mention) this.lastMentionAt.set(group.id, mention.timestamp);
|
|
15141
15657
|
}
|
|
@@ -15177,6 +15693,7 @@ var Groups = class {
|
|
|
15177
15693
|
profile: "mesh",
|
|
15178
15694
|
createdAt: group.createdAt,
|
|
15179
15695
|
lastMessageAt: this.lastMessageAt.get(group.id) ?? 0,
|
|
15696
|
+
...peerMessageAt(this.lastPeerMessageAt, group.id),
|
|
15180
15697
|
...mentionAt(this.lastMentionAt, group.id),
|
|
15181
15698
|
invited: [...this.invited.get(group.id) ?? []],
|
|
15182
15699
|
memberLinks: Object.fromEntries(contacts.map(([key, linkId]) => [linkId, key]))
|
|
@@ -15208,9 +15725,11 @@ var Groups = class {
|
|
|
15208
15725
|
const hubKeys = new Set(onHubs ? this.hubs.hubs(group.id, session, now) : []), policy = session.hubPolicy;
|
|
15209
15726
|
if (onHubs && this.hubs.isHub(group.id)) hubKeys.add(session.myKey);
|
|
15210
15727
|
const typing = this.typings.view(session);
|
|
15728
|
+
const former = Object.entries(nicks).filter(([key]) => !rosterHas(session.roster, key));
|
|
15211
15729
|
return {
|
|
15212
15730
|
...base,
|
|
15213
15731
|
name: session.name,
|
|
15732
|
+
...former.length ? { formerNames: Object.fromEntries(former) } : {},
|
|
15214
15733
|
status: session.status,
|
|
15215
15734
|
statusReason: session.state.statusReason,
|
|
15216
15735
|
epoch: session.epoch,
|
|
@@ -15292,7 +15811,8 @@ var Groups = class {
|
|
|
15292
15811
|
v: edit.e,
|
|
15293
15812
|
ts: edit.ts,
|
|
15294
15813
|
text: edit.m,
|
|
15295
|
-
mentions: edit.k
|
|
15814
|
+
mentions: edit.k,
|
|
15815
|
+
...edit.sc && { card: edit.sc }
|
|
15296
15816
|
}, to);
|
|
15297
15817
|
return "error" in result ? result.error : null;
|
|
15298
15818
|
}
|
|
@@ -15365,13 +15885,13 @@ var Groups = class {
|
|
|
15365
15885
|
}
|
|
15366
15886
|
/**
|
|
15367
15887
|
* Sends a text; `messageId` is the id it is kept under here (what history, replies and reactions name). `forwarded`:
|
|
15368
|
-
* the hop count of a forwarded text (WISP 9xx § Forwards).
|
|
15888
|
+
* the hop count of a forwarded text (WISP 9xx § Forwards). `card`: a checked status card, the text its fallback.
|
|
15369
15889
|
*/
|
|
15370
|
-
async send(groupId, text, mentions = [], reply, forwarded) {
|
|
15371
|
-
if (this.isCommunity(groupId)) return this.communities.send(groupId, text, mentions, reply, forwarded);
|
|
15890
|
+
async send(groupId, text, mentions = [], reply, forwarded, card) {
|
|
15891
|
+
if (this.isCommunity(groupId)) return card ? this.communities.send(groupId, text, mentions, reply, forwarded, card) : this.communities.send(groupId, text, mentions, reply, forwarded);
|
|
15372
15892
|
const session = this.sessions.get(groupId);
|
|
15373
15893
|
if (!session) return { error: "You are not in this group yet" };
|
|
15374
|
-
const result = await session.sendText(text, Date.now(), mentions, reply, forwarded);
|
|
15894
|
+
const result = card ? await session.sendText(text, Date.now(), mentions, reply, forwarded, card) : await session.sendText(text, Date.now(), mentions, reply, forwarded);
|
|
15375
15895
|
this.typings.say(session, false);
|
|
15376
15896
|
return "error" in result ? { error: result.error } : {
|
|
15377
15897
|
error: null,
|
|
@@ -15438,9 +15958,11 @@ var Groups = class {
|
|
|
15438
15958
|
this.invited.delete(groupId);
|
|
15439
15959
|
this.pendingEntries.delete(groupId);
|
|
15440
15960
|
this.lastMessageAt.delete(groupId);
|
|
15961
|
+
this.lastPeerMessageAt.delete(groupId);
|
|
15441
15962
|
this.lastMentionAt.delete(groupId);
|
|
15442
15963
|
await this.store.deleteGroup(groupId);
|
|
15443
15964
|
await this.store.putGroup(group);
|
|
15965
|
+
this.host.historyGone?.(groupId);
|
|
15444
15966
|
for (const linkId of this.host.entries(groupId).values()) await this.host.closeEdge(linkId);
|
|
15445
15967
|
this.host.emit();
|
|
15446
15968
|
await session.leave();
|
|
@@ -15524,6 +16046,7 @@ var Groups = class {
|
|
|
15524
16046
|
this.hereActed.delete(groupId);
|
|
15525
16047
|
for (const linkId of [...this.host.edges(groupId).values(), ...this.host.entries(groupId).values()]) await this.host.closeEdge(linkId);
|
|
15526
16048
|
await this.store.deleteGroup(groupId);
|
|
16049
|
+
this.host.historyGone?.(groupId);
|
|
15527
16050
|
this.host.membersChanged?.(groupId);
|
|
15528
16051
|
this.host.emit();
|
|
15529
16052
|
}
|
|
@@ -16224,7 +16747,6 @@ var Groups = class {
|
|
|
16224
16747
|
message: async (m) => {
|
|
16225
16748
|
const timestamp = receivedTimestamp(m.timestamp);
|
|
16226
16749
|
const mentioned = m.sender !== session.myKey && mentionsMember(m.mentions, session.myKey);
|
|
16227
|
-
if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, timestamp));
|
|
16228
16750
|
const message = {
|
|
16229
16751
|
linkId: MESSAGE_LINK(state.id),
|
|
16230
16752
|
id: m.id,
|
|
@@ -16235,10 +16757,14 @@ var Groups = class {
|
|
|
16235
16757
|
via: "datalink",
|
|
16236
16758
|
...mentionFields(m.mentions, mentioned),
|
|
16237
16759
|
...m.reply && { replyTo: groupReply(m.reply, session.myKey) },
|
|
16238
|
-
...m.forwarded && { forwarded: m.forwarded }
|
|
16760
|
+
...m.forwarded && { forwarded: m.forwarded },
|
|
16761
|
+
...m.card && { card: m.card }
|
|
16239
16762
|
};
|
|
16240
|
-
|
|
16763
|
+
const stored = m.completes && this.host.completeMessage ? (await this.host.completeMessage(message), false) : await this.host.storeMessage(message);
|
|
16241
16764
|
this.lastMessageAt.set(state.id, Math.max(this.lastMessageAt.get(state.id) ?? 0, timestamp));
|
|
16765
|
+
const came = cameAt(timestamp, stored, this.now());
|
|
16766
|
+
if (mentioned) this.lastMentionAt.set(state.id, Math.max(this.lastMentionAt.get(state.id) ?? 0, came));
|
|
16767
|
+
if (m.sender !== session.myKey) this.lastPeerMessageAt.set(state.id, Math.max(this.lastPeerMessageAt.get(state.id) ?? 0, came));
|
|
16242
16768
|
this.typings.messageFrom(state.id, m.sender);
|
|
16243
16769
|
},
|
|
16244
16770
|
edit: (e) => this.host.groupEdit?.(state.id, e),
|
|
@@ -16352,8 +16878,14 @@ var Groups = class {
|
|
|
16352
16878
|
* An edge of a group as the group page shows it (WISP 9xx § Mesh): open means group frames flow, which
|
|
16353
16879
|
* is what "reachable" means everywhere else in groups; what carries it; when the member was last heard.
|
|
16354
16880
|
*/
|
|
16355
|
-
function edgeView(live, now = Date.now()) {
|
|
16881
|
+
function edgeView(live, now = Date.now(), noSlot = false) {
|
|
16356
16882
|
const open = !!live.link?.groupsSupport;
|
|
16883
|
+
if (!open && noSlot) return {
|
|
16884
|
+
linkId: live.stored.id,
|
|
16885
|
+
state: "waiting",
|
|
16886
|
+
noSlot: true,
|
|
16887
|
+
lastSeenAt: Math.max(live.presence.lastPacketAt, live.lastSyncAt)
|
|
16888
|
+
};
|
|
16357
16889
|
const error = open ? void 0 : live.pairing?.status === "error" ? live.pairing.error : live.discoveryError;
|
|
16358
16890
|
const state = open ? "open" : error ? "error" : live.dataLink !== "idle" ? "connecting" : "waiting";
|
|
16359
16891
|
return {
|
|
@@ -16686,6 +17218,8 @@ var GroupEdits = class {
|
|
|
16686
17218
|
host;
|
|
16687
17219
|
now;
|
|
16688
17220
|
buffer;
|
|
17221
|
+
/** Members' status card updates, applied at most once a second per message (WISP 4xx · Status Cards). */
|
|
17222
|
+
cards;
|
|
16689
17223
|
sendPace = /* @__PURE__ */ new Map();
|
|
16690
17224
|
receivePace = /* @__PURE__ */ new Map();
|
|
16691
17225
|
timers = /* @__PURE__ */ new Map();
|
|
@@ -16695,12 +17229,14 @@ var GroupEdits = class {
|
|
|
16695
17229
|
this.host = host;
|
|
16696
17230
|
this.now = host.now ?? Date.now;
|
|
16697
17231
|
this.buffer = new EditBuffer(this.now);
|
|
17232
|
+
this.cards = new CardEditPacer(this.now);
|
|
16698
17233
|
}
|
|
16699
17234
|
/**
|
|
16700
17235
|
* Edits a text of mine in a group: the new text here at once, and to the group as soon as the pace allows. Mentions
|
|
16701
|
-
* the message had stay where their words still are; `added` are those picked while editing.
|
|
17236
|
+
* the message had stay where their words still are; `added` are those picked while editing. `card`: the status card
|
|
17237
|
+
* of the new version, already checked (WISP 4xx · Status Cards); a card message then takes `STATUS_CARD_LIMITS.edits`.
|
|
16702
17238
|
*/
|
|
16703
|
-
async edit(groupId, messageId, raw, added = []) {
|
|
17239
|
+
async edit(groupId, messageId, raw, added = [], card) {
|
|
16704
17240
|
const refuse = (error) => ({
|
|
16705
17241
|
error,
|
|
16706
17242
|
refused: true
|
|
@@ -16716,23 +17252,25 @@ var GroupEdits = class {
|
|
|
16716
17252
|
if (isJoinNotice(text)) return refuse("An edit cannot read as a join notice.");
|
|
16717
17253
|
if (utf8Encode(text).length > 16384) return refuse(`Message exceeds ${GROUP_EDIT_TEXT_BYTES} UTF-8 bytes.`);
|
|
16718
17254
|
const mentions = carryMentions(message, text, Array.isArray(added) ? added : [], !membership.community && !!membership.admin);
|
|
16719
|
-
if (text === message.text && JSON.stringify(mentions) === JSON.stringify(message.mentions ?? [])) return {
|
|
17255
|
+
if (text === message.text && JSON.stringify(mentions) === JSON.stringify(message.mentions ?? []) && JSON.stringify(card ?? null) === JSON.stringify(message.card ?? null)) return {
|
|
16720
17256
|
error: null,
|
|
16721
17257
|
messageId
|
|
16722
17258
|
};
|
|
16723
|
-
const seq = (message.edit?.seq ?? 0) + 1;
|
|
16724
|
-
if (seq >
|
|
17259
|
+
const seq = (message.edit?.seq ?? 0) + 1, most = card ? STATUS_CARD_LIMITS.edits : 100;
|
|
17260
|
+
if (seq > most) return refuse(`This message was edited ${most} times, the most one takes.`);
|
|
16725
17261
|
const at = this.now();
|
|
16726
17262
|
await this.host.patch(chat, messageId, (current) => {
|
|
16727
17263
|
const next = withEdit(current, {
|
|
16728
17264
|
seq,
|
|
16729
17265
|
at,
|
|
16730
17266
|
text,
|
|
17267
|
+
card,
|
|
16731
17268
|
pending: true
|
|
16732
17269
|
});
|
|
16733
17270
|
return {
|
|
16734
17271
|
text: next.text,
|
|
16735
17272
|
edit: next.edit,
|
|
17273
|
+
card: next.card,
|
|
16736
17274
|
mentions: mentions.length ? mentions : void 0
|
|
16737
17275
|
};
|
|
16738
17276
|
});
|
|
@@ -16775,7 +17313,8 @@ var GroupEdits = class {
|
|
|
16775
17313
|
e: edit.seq,
|
|
16776
17314
|
ts: edit.at,
|
|
16777
17315
|
m: message.text,
|
|
16778
|
-
...message.mentions?.length && { k: message.mentions }
|
|
17316
|
+
...message.mentions?.length && { k: message.mentions },
|
|
17317
|
+
...message.card && { sc: message.card }
|
|
16779
17318
|
})) {
|
|
16780
17319
|
retry = Math.min(retry, RETRY_MS);
|
|
16781
17320
|
continue;
|
|
@@ -16817,6 +17356,12 @@ var GroupEdits = class {
|
|
|
16817
17356
|
});
|
|
16818
17357
|
return "waiting";
|
|
16819
17358
|
}
|
|
17359
|
+
if (edit.sc && message.member === sender && (message.edit?.seq ?? 0) < edit.e) {
|
|
17360
|
+
await this.cards.take(`${chat}\n${message.id}`, edit.e, async () => {
|
|
17361
|
+
await this.apply(chat, membership.me, sender, message, edit);
|
|
17362
|
+
});
|
|
17363
|
+
return "applied";
|
|
17364
|
+
}
|
|
16820
17365
|
return this.apply(chat, membership.me, sender, message, edit);
|
|
16821
17366
|
}
|
|
16822
17367
|
/** A member's message was stored: an edit that came before it is shown now. */
|
|
@@ -16835,11 +17380,13 @@ var GroupEdits = class {
|
|
|
16835
17380
|
const next = withEdit(current, {
|
|
16836
17381
|
seq: edit.e,
|
|
16837
17382
|
at: receivedTimestamp(edit.ts),
|
|
16838
|
-
text: edit.m
|
|
17383
|
+
text: edit.m,
|
|
17384
|
+
card: edit.sc
|
|
16839
17385
|
});
|
|
16840
17386
|
return {
|
|
16841
17387
|
text: next.text,
|
|
16842
17388
|
edit: next.edit,
|
|
17389
|
+
card: next.card,
|
|
16843
17390
|
mentions: edit.k?.length ? edit.k : void 0,
|
|
16844
17391
|
mentioned: mentionsMember(edit.k, me) ? true : void 0
|
|
16845
17392
|
};
|
|
@@ -16855,7 +17402,8 @@ var GroupEdits = class {
|
|
|
16855
17402
|
e: m.edit.seq,
|
|
16856
17403
|
ts: m.edit.at,
|
|
16857
17404
|
m: m.text,
|
|
16858
|
-
...m.mentions?.length && { k: m.mentions }
|
|
17405
|
+
...m.mentions?.length && { k: m.mentions },
|
|
17406
|
+
...m.card && { sc: m.card }
|
|
16859
17407
|
}, to);
|
|
16860
17408
|
}
|
|
16861
17409
|
forget(groupId) {
|
|
@@ -16865,6 +17413,7 @@ var GroupEdits = class {
|
|
|
16865
17413
|
}
|
|
16866
17414
|
stop() {
|
|
16867
17415
|
this.stopped = true;
|
|
17416
|
+
this.cards.stop();
|
|
16868
17417
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
16869
17418
|
this.timers.clear();
|
|
16870
17419
|
}
|
|
@@ -17645,6 +18194,20 @@ var HoldEngine = class {
|
|
|
17645
18194
|
if (entry.state === "held") await this.publishPointer(linkId, true).catch(() => {});
|
|
17646
18195
|
});
|
|
17647
18196
|
}
|
|
18197
|
+
/**
|
|
18198
|
+
* "Clear all data": stops, then deletes every item held in storage for every contact, and each chat's manifest. A
|
|
18199
|
+
* contact that still has the pointer finds nothing behind it. Nothing is written back to the chats: they go next.
|
|
18200
|
+
*/
|
|
18201
|
+
async withdrawAll() {
|
|
18202
|
+
await this.stop();
|
|
18203
|
+
const storage = this.host.storage();
|
|
18204
|
+
if (!storage) return;
|
|
18205
|
+
const names = this.host.linkIds().flatMap((linkId) => {
|
|
18206
|
+
const hold = this.state(linkId);
|
|
18207
|
+
return [...hold.outbox.map((entry) => entry.name), ...hold.mailbox ? [manifestName(storage.space, hold.mailbox)] : []];
|
|
18208
|
+
});
|
|
18209
|
+
await Promise.allSettled(names.map((name) => storage.store.remove(name)));
|
|
18210
|
+
}
|
|
17648
18211
|
forgetLink(linkId) {
|
|
17649
18212
|
this.keyCache.delete(linkId);
|
|
17650
18213
|
this.lastPoll.delete(linkId);
|
|
@@ -18182,6 +18745,47 @@ var TransportLog = class {
|
|
|
18182
18745
|
}
|
|
18183
18746
|
}
|
|
18184
18747
|
/**
|
|
18748
|
+
* When the chat was last live, as far as the history tells: the latest time a live stretch began, moved or ended.
|
|
18749
|
+
* 0 when it never was.
|
|
18750
|
+
*/
|
|
18751
|
+
get lastLiveAt() {
|
|
18752
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
18753
|
+
const e = this.history[i];
|
|
18754
|
+
if (e.kind === "down" && e.restart) continue;
|
|
18755
|
+
if (e.kind === "live" || e.kind === "switched" || e.kind === "down") return e.at;
|
|
18756
|
+
}
|
|
18757
|
+
return 0;
|
|
18758
|
+
}
|
|
18759
|
+
/**
|
|
18760
|
+
* When the live stretch `liveAtLastRun` names began: a contact's offer from before it set that session up (or an
|
|
18761
|
+
* older one) and is not answered after a restart (`GhostLinkOptions.resumeFloor`). None when the history ends off live.
|
|
18762
|
+
*/
|
|
18763
|
+
get liveSinceAtLastRun() {
|
|
18764
|
+
for (let i = this.history.length - 1; i >= 0; i--) {
|
|
18765
|
+
const e = this.history[i];
|
|
18766
|
+
if (e.kind === "live") return e.at;
|
|
18767
|
+
if (e.kind === "down" || e.kind === "dht-only") return void 0;
|
|
18768
|
+
}
|
|
18769
|
+
}
|
|
18770
|
+
/**
|
|
18771
|
+
* This run has lasted past the contact's liveness bound, and the chat was never live in it: the session it was live
|
|
18772
|
+
* in when the app last ran is gone on the contact's side too. The history says so, and the next start no longer
|
|
18773
|
+
* takes the chat for "live at last run" and knocks: before, a contact that never came back was knocked on at every
|
|
18774
|
+
* start, run after run (all ten chats of the #966 repro). Written during the run, not as it ends: a page reload or a
|
|
18775
|
+
* crash records nothing then. This run's own knock already went; only the next start reads it. True when written.
|
|
18776
|
+
*/
|
|
18777
|
+
notBackAfterRestart(now) {
|
|
18778
|
+
const from = this.seenLive ? void 0 : this.liveAtLastRun;
|
|
18779
|
+
if (!from) return false;
|
|
18780
|
+
this.event({
|
|
18781
|
+
at: now,
|
|
18782
|
+
kind: "down",
|
|
18783
|
+
from,
|
|
18784
|
+
restart: true
|
|
18785
|
+
});
|
|
18786
|
+
return true;
|
|
18787
|
+
}
|
|
18788
|
+
/**
|
|
18185
18789
|
* The choice `by` last made, as the rows tell it: the transport of its latest choice (a `chose` row, or the switch
|
|
18186
18790
|
* that choice became), `automatic` when that was going back to the app's rule or there is none.
|
|
18187
18791
|
*/
|
|
@@ -19066,10 +19670,10 @@ function normalizeMintUrl(input) {
|
|
|
19066
19670
|
try {
|
|
19067
19671
|
url = new URL(input.trim());
|
|
19068
19672
|
} catch {
|
|
19069
|
-
throw
|
|
19673
|
+
throw engineError("invalidMintUrl");
|
|
19070
19674
|
}
|
|
19071
19675
|
const local = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
|
19072
|
-
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw
|
|
19676
|
+
if (url.protocol !== "https:" && !(local && url.protocol === "http:")) throw engineError("mintNotHttps");
|
|
19073
19677
|
return `${url.origin}${url.pathname.replace(/\/+$/, "")}`;
|
|
19074
19678
|
}
|
|
19075
19679
|
function assertAmount(amount) {
|
|
@@ -19100,6 +19704,8 @@ var walletTx = (mint, kind, amount, fee, note) => ({
|
|
|
19100
19704
|
fee: Math.max(0, fee),
|
|
19101
19705
|
note: note?.slice(0, 140)
|
|
19102
19706
|
});
|
|
19707
|
+
/** What a failed payment left in the wallet, said to the person: all of it, or all but the fee the mint kept. */
|
|
19708
|
+
var backInWallet = (lost) => lost > 0 ? `The sats are back in your wallet, less ${lost} sat${lost === 1 ? "" : "s"} the mint kept as its fee.` : "The sats are back in your wallet.";
|
|
19103
19709
|
/**
|
|
19104
19710
|
* How long a mint gets to answer when it is only being asked something (its keys, an invoice). A mint
|
|
19105
19711
|
* that is down can hold a request for minutes; the next mint should get its turn well before that.
|
|
@@ -19122,6 +19728,8 @@ var CashuWallet = class {
|
|
|
19122
19728
|
locks = /* @__PURE__ */ new Map();
|
|
19123
19729
|
quoteTimer = null;
|
|
19124
19730
|
meltTimer = null;
|
|
19731
|
+
/** Per melt quote that ended unpaid: the sats the mint kept all the same (the fee of the split before the melt). */
|
|
19732
|
+
failedFees = /* @__PURE__ */ new Map();
|
|
19125
19733
|
/**
|
|
19126
19734
|
* `getMints`: the mints of one network (the engine's default when none is named), primary first: balances,
|
|
19127
19735
|
* invoices, payments of that network's Cashu wallet. `getKnownMints`: every mint the user added, whatever its
|
|
@@ -19162,7 +19770,7 @@ var CashuWallet = class {
|
|
|
19162
19770
|
try {
|
|
19163
19771
|
wallet = await this.wallet(normalized);
|
|
19164
19772
|
} catch (error) {
|
|
19165
|
-
throw Object.assign(
|
|
19773
|
+
throw Object.assign(engineError("mintUnreachable", { host: new URL(normalized).host }), { cause: error });
|
|
19166
19774
|
}
|
|
19167
19775
|
const info = wallet.getMintInfo();
|
|
19168
19776
|
const name = info.name || new URL(normalized).hostname;
|
|
@@ -19396,7 +20004,7 @@ var CashuWallet = class {
|
|
|
19396
20004
|
};
|
|
19397
20005
|
});
|
|
19398
20006
|
}
|
|
19399
|
-
throw
|
|
20007
|
+
throw engineError(candidates.length === 0 && preferred ? "noSharedMint" : "notEnoughSats");
|
|
19400
20008
|
}
|
|
19401
20009
|
async prepareReviewedCashu(mint, amount) {
|
|
19402
20010
|
assertAmount(amount);
|
|
@@ -19416,7 +20024,7 @@ var CashuWallet = class {
|
|
|
19416
20024
|
async executeReviewedCashu(review, prepared) {
|
|
19417
20025
|
return this.locked(prepared.mint, async () => {
|
|
19418
20026
|
const preview = deserializeSwapPreview(prepared.swap);
|
|
19419
|
-
if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || reviewedCashuFee(preview) !== review.fee) throw new
|
|
20027
|
+
if (prepared.mint !== review.provider || preview.amount.toNumber() !== review.amount || reviewedCashuFee(preview) !== review.fee) throw new PaymentPreflightError("Cashu preview does not match review");
|
|
19420
20028
|
const tx = (await openDb()).transaction(STORES.proofs, "readwrite");
|
|
19421
20029
|
await new Promise((resolve, reject) => {
|
|
19422
20030
|
const proofs = tx.objectStore(STORES.proofs);
|
|
@@ -19435,7 +20043,7 @@ var CashuWallet = class {
|
|
|
19435
20043
|
};
|
|
19436
20044
|
}
|
|
19437
20045
|
tx.oncomplete = () => resolve();
|
|
19438
|
-
tx.onabort = tx.onerror = () => reject(
|
|
20046
|
+
tx.onabort = tx.onerror = () => reject(new PaymentPreflightError(engineText("reviewedSatsGone")));
|
|
19439
20047
|
});
|
|
19440
20048
|
const { keep, send } = await (await this.wallet(prepared.mint)).completeSwap(preview);
|
|
19441
20049
|
return this.finishReviewedCashu(review, prepared, keep, send);
|
|
@@ -19500,6 +20108,42 @@ var CashuWallet = class {
|
|
|
19500
20108
|
this.events.onChange();
|
|
19501
20109
|
return token;
|
|
19502
20110
|
}
|
|
20111
|
+
/**
|
|
20112
|
+
* Proof from the mint that a reviewed swap never happened: every input it would spend reads UNSPENT (not PENDING,
|
|
20113
|
+
* not SPENT), and the mint signed none of its outputs. Read-only: it asks, and never sends the swap.
|
|
20114
|
+
*/
|
|
20115
|
+
async reviewedCashuNeverSwapped(prepared) {
|
|
20116
|
+
const preview = deserializeSwapPreview(prepared.swap);
|
|
20117
|
+
if (!preview.inputs.length) return false;
|
|
20118
|
+
const wallet = await this.wallet(prepared.mint);
|
|
20119
|
+
const states = await wallet.checkProofsStates(preview.inputs.map((p) => ({
|
|
20120
|
+
secret: p.secret,
|
|
20121
|
+
id: p.id
|
|
20122
|
+
})));
|
|
20123
|
+
if (states.length !== preview.inputs.length || !states.every((s) => s.state === "UNSPENT")) return false;
|
|
20124
|
+
const outputs = [...prepared.swap.keepOutputs ?? [], ...prepared.swap.sendOutputs ?? []].map(OutputData.deserialize);
|
|
20125
|
+
const restored = await wallet.mint.restore({ outputs: outputs.map((o) => o.blindedMessage) });
|
|
20126
|
+
return restored.signatures.length === 0 && restored.outputs.length === 0;
|
|
20127
|
+
}
|
|
20128
|
+
/** Gives back the inputs a reviewed swap reserved, once the mint proved it never happened. Idempotent. */
|
|
20129
|
+
async releaseReviewedCashu(prepared) {
|
|
20130
|
+
const preview = deserializeSwapPreview(prepared.swap);
|
|
20131
|
+
await this.locked(prepared.mint, async () => {
|
|
20132
|
+
await transact([STORES.proofs], (stores) => {
|
|
20133
|
+
const proofs = stores[STORES.proofs];
|
|
20134
|
+
for (const input of preview.inputs) {
|
|
20135
|
+
const request = proofs.get(input.secret);
|
|
20136
|
+
request.onsuccess = () => {
|
|
20137
|
+
const proof = request.result;
|
|
20138
|
+
if (!proof || !proof.reserved || proof.mint !== prepared.mint || proof.C !== input.C) return;
|
|
20139
|
+
const { reserved: _released, ...free } = proof;
|
|
20140
|
+
proofs.put(free);
|
|
20141
|
+
};
|
|
20142
|
+
}
|
|
20143
|
+
});
|
|
20144
|
+
});
|
|
20145
|
+
this.events.onChange();
|
|
20146
|
+
}
|
|
19503
20147
|
async reviewedCashuSpent(prepared) {
|
|
19504
20148
|
const outputs = (prepared.swap.sendOutputs ?? []).map(OutputData.deserialize);
|
|
19505
20149
|
const states = await (await this.wallet(prepared.mint)).checkProofsStates(outputs.map((o) => ({
|
|
@@ -19587,7 +20231,7 @@ var CashuWallet = class {
|
|
|
19587
20231
|
const amount = quote.amount.toNumber();
|
|
19588
20232
|
const feeReserve = quote.fee_reserve.toNumber();
|
|
19589
20233
|
if (await this.balanceAt(mint) < amount + feeReserve) {
|
|
19590
|
-
lastError =
|
|
20234
|
+
lastError = engineError("notEnoughSats");
|
|
19591
20235
|
continue;
|
|
19592
20236
|
}
|
|
19593
20237
|
return {
|
|
@@ -19609,7 +20253,7 @@ var CashuWallet = class {
|
|
|
19609
20253
|
async payQuote(quoteId, mint, note, paymentId) {
|
|
19610
20254
|
try {
|
|
19611
20255
|
const outcome = await this.locked(mint, () => this.melt(quoteId, mint, note, paymentId));
|
|
19612
|
-
if (outcome === "unpaid") throw new Error(
|
|
20256
|
+
if (outcome === "unpaid") throw new Error(`The Lightning payment did not go through. ${backInWallet(this.takeFailedFee(quoteId))}`);
|
|
19613
20257
|
if (outcome === "pending") this.pollMelts();
|
|
19614
20258
|
return outcome === "paid";
|
|
19615
20259
|
} finally {
|
|
@@ -19653,7 +20297,11 @@ var CashuWallet = class {
|
|
|
19653
20297
|
} catch (error) {
|
|
19654
20298
|
if (!isMintOperationError(error) && !(error instanceof MeltChangeError)) return "pending";
|
|
19655
20299
|
const outcome = await this.settleMelt(melt).catch(() => "pending");
|
|
19656
|
-
if (outcome === "unpaid")
|
|
20300
|
+
if (outcome === "unpaid") {
|
|
20301
|
+
const lost = this.takeFailedFee(melt.quote);
|
|
20302
|
+
if (lost > 0) throw Object.assign(/* @__PURE__ */ new Error(`${error instanceof Error ? error.message : String(error)} ${backInWallet(lost)}`), { cause: error });
|
|
20303
|
+
throw error;
|
|
20304
|
+
}
|
|
19657
20305
|
return outcome;
|
|
19658
20306
|
}
|
|
19659
20307
|
if (result.quote.state === "PAID") {
|
|
@@ -19673,6 +20321,7 @@ var CashuWallet = class {
|
|
|
19673
20321
|
return current ? this.settleMelt(current) : null;
|
|
19674
20322
|
});
|
|
19675
20323
|
if (outcome === "paid" || outcome === "unpaid") {
|
|
20324
|
+
this.takeFailedFee(melt.quote);
|
|
19676
20325
|
this.events.onMeltResolved(melt, outcome === "paid");
|
|
19677
20326
|
this.events.onChange();
|
|
19678
20327
|
}
|
|
@@ -19707,16 +20356,29 @@ var CashuWallet = class {
|
|
|
19707
20356
|
if (states.some((s) => s.state === "PENDING")) return "pending";
|
|
19708
20357
|
const spent = new Set(inputs.filter((_, i) => states[i]?.state === "SPENT").map((p) => p.secret));
|
|
19709
20358
|
if (spent.size > 0) console.warn(`[wallet] melt ${melt.quote} is unpaid, yet ${spent.size} of its proofs are spent`);
|
|
19710
|
-
|
|
20359
|
+
const lost = melt.outlay - inputs.filter((p) => !spent.has(p.secret)).reduce((sum, p) => sum + p.amount, 0);
|
|
20360
|
+
await transact([
|
|
20361
|
+
STORES.proofs,
|
|
20362
|
+
STORES.melts,
|
|
20363
|
+
...lost > 0 ? [STORES.walletTx] : []
|
|
20364
|
+
], (stores) => {
|
|
19711
20365
|
for (const p of inputs) if (spent.has(p.secret)) stores[STORES.proofs].delete(p.secret);
|
|
19712
20366
|
else stores[STORES.proofs].put({
|
|
19713
20367
|
...p,
|
|
19714
20368
|
reserved: false
|
|
19715
20369
|
});
|
|
19716
20370
|
stores[STORES.melts].delete(melt.quote);
|
|
20371
|
+
if (lost > 0) stores[STORES.walletTx].put(walletTx(melt.mint, "fee", 0, lost, melt.note ? `Payment failed: ${melt.note}` : "A Lightning payment failed"));
|
|
19717
20372
|
});
|
|
20373
|
+
if (lost > 0) this.failedFees.set(melt.quote, lost);
|
|
19718
20374
|
return "unpaid";
|
|
19719
20375
|
}
|
|
20376
|
+
/** The fee a failed melt cost, once: for the words that say it failed. */
|
|
20377
|
+
takeFailedFee(quote) {
|
|
20378
|
+
const lost = this.failedFees.get(quote) ?? 0;
|
|
20379
|
+
this.failedFees.delete(quote);
|
|
20380
|
+
return lost;
|
|
20381
|
+
}
|
|
19720
20382
|
/** The invoice is paid: the reserved proofs are spent, the change is ours, and the payment goes in the history. */
|
|
19721
20383
|
async finishMelt(melt, change) {
|
|
19722
20384
|
const tx = walletTx(melt.mint, "lightning-out", melt.amount, melt.outlay - sats(change) - melt.amount, melt.note);
|
|
@@ -19833,6 +20495,8 @@ function parseSats(value, asset) {
|
|
|
19833
20495
|
/** Paying in ecash failed before any token existed, so nothing reached the contact and Lightning is safe to try. */
|
|
19834
20496
|
var NoEcashError = class extends Error {};
|
|
19835
20497
|
var arkSats = (network) => network === "bitcoin" ? "sats" : "test sats";
|
|
20498
|
+
/** The English line kept with a payment message (the CLI and older apps show it): test sats say so. */
|
|
20499
|
+
var satsOn = (network) => network === "testnet" ? "test sats" : "sats";
|
|
19836
20500
|
var ENDPOINT_OF = {
|
|
19837
20501
|
arkade: ENDPOINT.arkade,
|
|
19838
20502
|
bark: ENDPOINT.bark,
|
|
@@ -19911,8 +20575,12 @@ var PaymentDesk = class {
|
|
|
19911
20575
|
accepts(linkId, method, network) {
|
|
19912
20576
|
return this.host.acceptsNetwork?.(linkId, method, network) ?? true;
|
|
19913
20577
|
}
|
|
19914
|
-
/**
|
|
20578
|
+
/**
|
|
20579
|
+
* The contact has a wallet of that network for the method, as it said on the open session; an older contact says
|
|
20580
|
+
* nothing, and may. With no session open its last word is old (it may have made a wallet since): it may too.
|
|
20581
|
+
*/
|
|
19915
20582
|
meets(link, method, network) {
|
|
20583
|
+
if (!link.isDataLinkOpen) return true;
|
|
19916
20584
|
const theirs = link.peerPaymentNetworks?.(method);
|
|
19917
20585
|
return !theirs || theirs.includes(network);
|
|
19918
20586
|
}
|
|
@@ -20237,7 +20905,8 @@ var PaymentDesk = class {
|
|
|
20237
20905
|
};
|
|
20238
20906
|
const ecash = params.rail !== "lightning" && !offHere.cashu && (known ? known.includes("cashu") && link.paymentEnabled("cashu") : link.allowsPayment("cashu"));
|
|
20239
20907
|
const lightning = params.rail !== "cashu" && !offHere.lightning && (known ? known.includes("lightning") && link.paymentEnabled("lightning") : link.allowsPayment("lightning"));
|
|
20240
|
-
|
|
20908
|
+
const theirsOff = !!waiting && !waiting.includes("cashu") && !waiting.includes("lightning") && (link.paymentEnabled("cashu") || link.paymentEnabled("lightning"));
|
|
20909
|
+
if (!ecash && !lightning) throw new Error(params.rail && offHere[params.rail] ? offHere[params.rail] : params.rail ? `${params.rail === "cashu" ? "Cashu" : "Lightning"} is not allowed by both of you here` : held ? "Your contact allowed neither Cashu nor Lightning in this chat" : offHere.cashu && offHere.lightning ? offHere.cashu : theirsOff ? "Your contact took no Cashu or Lightning last time. Try again once the chat is live" : "Cashu and Lightning are off in this chat");
|
|
20241
20910
|
const quote = lightning ? await this.lightning[network].createInvoice(params.amount, id, params.card) : void 0;
|
|
20242
20911
|
const own = (await this.wallet.view(network)).mints.map((m) => m.url);
|
|
20243
20912
|
const mints = ecash ? own.filter((url) => isWorthlessMint(url) === (network === "testnet")) : [];
|
|
@@ -20319,7 +20988,7 @@ var PaymentDesk = class {
|
|
|
20319
20988
|
await this.host.storeMessage({
|
|
20320
20989
|
linkId,
|
|
20321
20990
|
id: `me_${params.timestamp}`,
|
|
20322
|
-
text: `⚡ Requested ${params.amount.toLocaleString()}
|
|
20991
|
+
text: `⚡ Requested ${params.amount.toLocaleString()} ${satsOn(network)} from the group`,
|
|
20323
20992
|
sender: "me",
|
|
20324
20993
|
timestamp: params.timestamp,
|
|
20325
20994
|
via: "datalink",
|
|
@@ -20402,7 +21071,7 @@ var PaymentDesk = class {
|
|
|
20402
21071
|
const quote = await this.lightning[network].quote(request.invoice, params.card);
|
|
20403
21072
|
if (quote.amount !== request.amount) throw new Error("The invoice does not match the requested amount");
|
|
20404
21073
|
const feeLimit = Math.min(Math.max(10, Math.ceil(request.amount * .03)), params.maxFee ?? Infinity);
|
|
20405
|
-
if (quote.feeReserve > feeLimit) throw
|
|
21074
|
+
if (quote.feeReserve > feeLimit) throw engineError("lightningFeeTooHigh", { fee: quote.feeReserve });
|
|
20406
21075
|
await this.save({
|
|
20407
21076
|
...this.current(request),
|
|
20408
21077
|
lightningPending: true,
|
|
@@ -20676,7 +21345,7 @@ var PaymentDesk = class {
|
|
|
20676
21345
|
await this.host.storeMessage({
|
|
20677
21346
|
linkId,
|
|
20678
21347
|
id: `peer_${request.timestamp}`,
|
|
20679
|
-
text: `⚡ Requested ${amount.toLocaleString()}
|
|
21348
|
+
text: `⚡ Requested ${amount.toLocaleString()} ${satsOn(network)}`,
|
|
20680
21349
|
sender: "peer",
|
|
20681
21350
|
timestamp: request.timestamp,
|
|
20682
21351
|
via: held ? "hold" : "datalink",
|
|
@@ -21233,7 +21902,7 @@ var PaymentDesk = class {
|
|
|
21233
21902
|
await this.host.storeMessage({
|
|
21234
21903
|
linkId: review.linkId,
|
|
21235
21904
|
id: `me_${review.createdAt}`,
|
|
21236
|
-
text: `${review.amount}
|
|
21905
|
+
text: `${review.amount} ${arkSats(review.network)} via Cashu`,
|
|
21237
21906
|
sender: "me",
|
|
21238
21907
|
timestamp: review.createdAt,
|
|
21239
21908
|
via: "datalink",
|
|
@@ -21961,9 +22630,11 @@ var PaymentDesk = class {
|
|
|
21961
22630
|
network: mintNetwork(mint)
|
|
21962
22631
|
});
|
|
21963
22632
|
let token;
|
|
22633
|
+
let paidFrom;
|
|
21964
22634
|
try {
|
|
21965
22635
|
const created = await this.wallet.createToken(params.amount, params.mints, memo, record, params.network);
|
|
21966
22636
|
token = created.token;
|
|
22637
|
+
paidFrom = created.mint;
|
|
21967
22638
|
this.payments.set(id, record(created.token, created.mint));
|
|
21968
22639
|
} catch (error) {
|
|
21969
22640
|
throw new NoEcashError(error instanceof Error ? error.message : String(error));
|
|
@@ -21972,7 +22643,7 @@ var PaymentDesk = class {
|
|
|
21972
22643
|
await this.host.storeMessage({
|
|
21973
22644
|
linkId: params.linkId,
|
|
21974
22645
|
id: `me_${params.timestamp}`,
|
|
21975
|
-
text: `⚡ ${params.amount.toLocaleString()}
|
|
22646
|
+
text: `⚡ ${params.amount.toLocaleString()} ${satsOn(mintNetwork(paidFrom))}`,
|
|
21976
22647
|
sender: "me",
|
|
21977
22648
|
timestamp: params.timestamp,
|
|
21978
22649
|
via: "datalink",
|
|
@@ -22335,7 +23006,7 @@ function transportCue(before, entries, history) {
|
|
|
22335
23006
|
}
|
|
22336
23007
|
/**
|
|
22337
23008
|
* The contact came with my invite: the first time the inviter's pairing shows them (their packet seen, or past the
|
|
22338
|
-
* wait), after it did not. The same test as the invite card's (src/lib/pairingProgress.ts `contactArrived`).
|
|
23009
|
+
* wait), after it did not. The same test as the invite card's (apps/ui/src/lib/pairingProgress.ts `contactArrived`).
|
|
22339
23010
|
*/
|
|
22340
23011
|
function knockCue(before, after) {
|
|
22341
23012
|
const arrived = (p) => !!p && (!!p.peerSeen || [
|
|
@@ -22413,12 +23084,123 @@ function pushRelayProblem(url) {
|
|
|
22413
23084
|
return "Use an https:// relay address";
|
|
22414
23085
|
}
|
|
22415
23086
|
//#endregion
|
|
22416
|
-
//#region ../browser/src/
|
|
23087
|
+
//#region ../browser/src/engine/walletSetup.ts
|
|
22417
23088
|
/**
|
|
22418
|
-
*
|
|
22419
|
-
*
|
|
22420
|
-
*
|
|
22421
|
-
|
|
23089
|
+
* What a new profile gets by itself on Mainnet, in this order: payments over Lightning (Cashu), a dollar token (USDT),
|
|
23090
|
+
* and Bitcoin on-chain. A kind with no Mainnet wallet here yet (on-chain, until a Mainnet source that needs no form
|
|
23091
|
+
* exists) is skipped quietly and stays in the list, never shown as a failure.
|
|
23092
|
+
*/
|
|
23093
|
+
var DEFAULT_WALLETS = [
|
|
23094
|
+
"cashu",
|
|
23095
|
+
"usdt",
|
|
23096
|
+
"bitcoin"
|
|
23097
|
+
];
|
|
23098
|
+
/** The network the first-run setup makes wallets on: real money, to receive right away. */
|
|
23099
|
+
var SETUP_NETWORK = "mainnet";
|
|
23100
|
+
var message = (error) => error instanceof Error ? error.message : String(error);
|
|
23101
|
+
/**
|
|
23102
|
+
* The first-run wallet setup: a new profile gets its default Mainnet wallets in the background, once. What is made is
|
|
23103
|
+
* never made again, so a wallet the person removes stays removed. A kind that could not be made (a server down, no
|
|
23104
|
+
* network) keeps its reason for the Wallet page and is tried again quietly at the next start, or when the person asks.
|
|
23105
|
+
* Creating a wallet moves no money.
|
|
23106
|
+
*/
|
|
23107
|
+
var WalletSetup = class {
|
|
23108
|
+
host;
|
|
23109
|
+
running = null;
|
|
23110
|
+
constructor(host) {
|
|
23111
|
+
this.host = host;
|
|
23112
|
+
}
|
|
23113
|
+
/** What the Wallet page shows: absent when there is nothing to say (done, or waiting only on a kind not offered). */
|
|
23114
|
+
view() {
|
|
23115
|
+
const record = this.host.record();
|
|
23116
|
+
const failed = (record?.left ?? []).flatMap((type) => {
|
|
23117
|
+
const reason = record?.failed?.[type];
|
|
23118
|
+
return reason ? [{
|
|
23119
|
+
type,
|
|
23120
|
+
network: SETUP_NETWORK,
|
|
23121
|
+
reason
|
|
23122
|
+
}] : [];
|
|
23123
|
+
});
|
|
23124
|
+
if (!this.running && !failed.length) return void 0;
|
|
23125
|
+
return {
|
|
23126
|
+
running: !!this.running,
|
|
23127
|
+
failed
|
|
23128
|
+
};
|
|
23129
|
+
}
|
|
23130
|
+
/**
|
|
23131
|
+
* At start. `fresh`: a new profile (nothing stored yet, no wallet, no chat). Only then is a setup begun; a profile
|
|
23132
|
+
* with a record goes on with what is left; any other profile (one from before this, or whose wallets were removed)
|
|
23133
|
+
* is left alone.
|
|
23134
|
+
*/
|
|
23135
|
+
async begin(fresh) {
|
|
23136
|
+
if (!this.host.record()) {
|
|
23137
|
+
if (!fresh) return;
|
|
23138
|
+
await this.host.save({ left: [...DEFAULT_WALLETS] });
|
|
23139
|
+
}
|
|
23140
|
+
await this.run();
|
|
23141
|
+
}
|
|
23142
|
+
/** Tries what is left (or only `only`), one after the other. A run under way is joined, not doubled. */
|
|
23143
|
+
run(only) {
|
|
23144
|
+
if (this.running) return this.running;
|
|
23145
|
+
this.running = this.work(only).finally(() => {
|
|
23146
|
+
this.running = null;
|
|
23147
|
+
this.host.changed();
|
|
23148
|
+
});
|
|
23149
|
+
this.host.changed();
|
|
23150
|
+
return this.running;
|
|
23151
|
+
}
|
|
23152
|
+
/** A wallet of this kind was made on this network (by the setup or by the person): nothing left to make of it. */
|
|
23153
|
+
async made(type, network) {
|
|
23154
|
+
if (network === "mainnet") await this.drop(type);
|
|
23155
|
+
}
|
|
23156
|
+
/** The person does not want this kind made for them: it is not tried again. */
|
|
23157
|
+
async dismiss(type) {
|
|
23158
|
+
await this.drop(type);
|
|
23159
|
+
this.host.changed();
|
|
23160
|
+
}
|
|
23161
|
+
async work(only) {
|
|
23162
|
+
for (const type of [...this.host.record()?.left ?? []]) {
|
|
23163
|
+
if (only && type !== only) continue;
|
|
23164
|
+
const offer = await this.host.offer(type);
|
|
23165
|
+
if (offer?.exists) {
|
|
23166
|
+
await this.drop(type);
|
|
23167
|
+
continue;
|
|
23168
|
+
}
|
|
23169
|
+
if (!offer?.available || offer.needs) continue;
|
|
23170
|
+
try {
|
|
23171
|
+
await this.host.create(type);
|
|
23172
|
+
await this.drop(type);
|
|
23173
|
+
} catch (error) {
|
|
23174
|
+
const record = this.host.record();
|
|
23175
|
+
if (record?.left.includes(type)) await this.host.save({
|
|
23176
|
+
...record,
|
|
23177
|
+
failed: {
|
|
23178
|
+
...record.failed,
|
|
23179
|
+
[type]: message(error)
|
|
23180
|
+
}
|
|
23181
|
+
});
|
|
23182
|
+
}
|
|
23183
|
+
this.host.changed();
|
|
23184
|
+
}
|
|
23185
|
+
}
|
|
23186
|
+
async drop(type) {
|
|
23187
|
+
const record = this.host.record();
|
|
23188
|
+
if (!record?.left.includes(type)) return;
|
|
23189
|
+
const failed = { ...record.failed };
|
|
23190
|
+
delete failed[type];
|
|
23191
|
+
await this.host.save({
|
|
23192
|
+
left: record.left.filter((t) => t !== type),
|
|
23193
|
+
...Object.keys(failed).length ? { failed } : {}
|
|
23194
|
+
});
|
|
23195
|
+
}
|
|
23196
|
+
};
|
|
23197
|
+
//#endregion
|
|
23198
|
+
//#region ../browser/src/platform/irohWeb.ts
|
|
23199
|
+
/**
|
|
23200
|
+
* Iroh (WISP 102) in a browser: the same QUIC/TLS session and binding as the
|
|
23201
|
+
* Desktop's native Iroh, but relay only. A page cannot send UDP, so every
|
|
23202
|
+
* packet goes through an Iroh relay over a WebSocket; the relay sees who talks
|
|
23203
|
+
* to whom and when, never the frames.
|
|
22422
23204
|
*
|
|
22423
23205
|
* The wasm (about 1 MB gzip) loads on the first chat that starts an endpoint,
|
|
22424
23206
|
* never on the app's first load.
|
|
@@ -22603,6 +23385,24 @@ async function createIrohWebEndpoint(seedB64, options = {}) {
|
|
|
22603
23385
|
//#region ../browser/src/engine/node.ts
|
|
22604
23386
|
/** How long a chat waits before listening again after its HyperDHT relay went away. */
|
|
22605
23387
|
var RELAY_RETRY_MS = 3e4;
|
|
23388
|
+
/** Native listeners an app may hold per transport (the Desktop's Rust Iroh allows eight, `paired_transport.rs`). */
|
|
23389
|
+
var NATIVE_SLOTS = 8;
|
|
23390
|
+
/** How often a group link that found no free native slot tries again. */
|
|
23391
|
+
var GROUP_NATIVE_RETRY_MS = 15e3;
|
|
23392
|
+
/** "Clear all data": how long taking things back from the network may hold up the clear, in all. */
|
|
23393
|
+
var CLEAR_WITHDRAW_MS = 6e3;
|
|
23394
|
+
/**
|
|
23395
|
+
* How soon the chat on screen tries again for a native slot when every one was busy (each dialling, or carrying a
|
|
23396
|
+
* session): a dial to a contact that is not there ends within its 20 s timeout, and leaves its slot free until the next.
|
|
23397
|
+
*/
|
|
23398
|
+
var ACTIVE_SLOT_RETRY_MS = 3e3;
|
|
23399
|
+
/**
|
|
23400
|
+
* How long a chat live when the app last ran may take to be live again before that stretch counts as over
|
|
23401
|
+
* (`TransportLog.notBackAfterRestart`): past the contact's liveness bound (three pings missed), the session it held is
|
|
23402
|
+
* gone too. Shorter, an app started twice in quick succession would not knock the second time on a contact still
|
|
23403
|
+
* holding the session.
|
|
23404
|
+
*/
|
|
23405
|
+
var RESUME_SPENT_MS = LIVENESS_PING_MS * 4;
|
|
22606
23406
|
var DEFAULT_SETTINGS = {
|
|
22607
23407
|
online: true,
|
|
22608
23408
|
nick: "",
|
|
@@ -22640,15 +23440,23 @@ var SPARE_INVITE_WARM_EVERY_MS = 24e4;
|
|
|
22640
23440
|
* it anyway. Three pairings in a minute are the whole of a page's relay budget (2026-09-27).
|
|
22641
23441
|
*/
|
|
22642
23442
|
var SPARE_INVITE_WARM_AFTER_TAKE_MS = 3e4;
|
|
23443
|
+
/**
|
|
23444
|
+
* At start (and back online), writes that can wait go this long after the chats' and groups' first packets: the spare
|
|
23445
|
+
* invite's warm-up, a chat's first control envelope, its capability record (once the chat's native endpoints are up).
|
|
23446
|
+
* A daemon back with two chats and two groups spent 25 of pkarr.pubky.org's 30 requests a minute on writes in its
|
|
23447
|
+
* first 11 s, and read nothing more for the rest of the minute, the others' answers included (bug hunt r7a, 2026-09-29).
|
|
23448
|
+
*/
|
|
23449
|
+
var STARTUP_QUIET_MS = 15e3;
|
|
22643
23450
|
var SPARE_INVITE_MAX_AGE_MS = 9e5;
|
|
22644
23451
|
/** How long a removal waits for a wallet's rail to claim what was already paid to it (tests shorten it). */
|
|
22645
23452
|
var removalTiming = { claimMs: 3e4 };
|
|
22646
23453
|
/** Why a removal needs the person's confirmation: what it holds, what it still waits for, and what confirming means. */
|
|
23454
|
+
/** `label`: what goes, as a noun ("Testnet Cashu wallet", "Testnet Lightning card “Home”"). */
|
|
22647
23455
|
function lossRefusal(label, removal) {
|
|
22648
23456
|
const { held, awaiting } = removal;
|
|
22649
|
-
const holds = held === "unknown" ? `Ghostly could not read what the ${label}
|
|
23457
|
+
const holds = held === "unknown" ? `Ghostly could not read what the ${label} holds.` : held.empty ? "" : `The ${label} holds ${held.text}.`;
|
|
22650
23458
|
const listed = awaiting.slice(0, 3).map((i) => i.text.charAt(0).toLowerCase() + i.text.slice(1)).join("; ") + (awaiting.length > 3 ? `; and ${awaiting.length - 3} more` : "");
|
|
22651
|
-
return `${holds}${awaiting.length ? `${holds ? " It" : `The ${label}
|
|
23459
|
+
return `${holds}${awaiting.length ? `${holds ? " It" : `The ${label}`} still waits for money: ${listed}.` : ""} Confirm that ${holds && awaiting.length ? `${held === "unknown" ? "anything in it becomes" : "they become"} unreachable without its backup, and that what is paid to it afterwards is lost,` : holds ? `${held === "unknown" ? "anything in it becomes" : "they become"} unreachable without its backup` : "what is paid to it after it is removed is lost"} to remove it.`;
|
|
22652
23460
|
}
|
|
22653
23461
|
function newLiveLink(stored, lastMessageAt, files = emptyLinkFiles()) {
|
|
22654
23462
|
return {
|
|
@@ -22719,11 +23527,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
22719
23527
|
activeLinkId = null;
|
|
22720
23528
|
nativeQueue = Promise.resolve();
|
|
22721
23529
|
shuttingDown = false;
|
|
23530
|
+
/** `start` has put the kept transfers back (EngineState.transfersRestored). */
|
|
23531
|
+
transfersRestored = false;
|
|
22722
23532
|
feedbackStartedAt = Date.now();
|
|
22723
23533
|
feedbackIds = /* @__PURE__ */ new Set();
|
|
22724
23534
|
walletFeedbackReady = false;
|
|
22725
23535
|
walletFeedbackIds = /* @__PURE__ */ new Set();
|
|
22726
|
-
/** `cue`: a finer sound the page plays instead of the event's own when its category is on (src/lib/cues.ts). */
|
|
23536
|
+
/** `cue`: a finer sound the page plays instead of the event's own when its category is on (apps/ui/src/lib/cues.ts). */
|
|
22727
23537
|
feedback(type, id, linkId, mention = false, cue) {
|
|
22728
23538
|
const key = type + ":" + id;
|
|
22729
23539
|
if (this.feedbackIds.has(key)) return;
|
|
@@ -22773,6 +23583,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
22773
23583
|
editQueues = /* @__PURE__ */ new Map();
|
|
22774
23584
|
/** The contacts' edits of messages not here yet. */
|
|
22775
23585
|
editBuffer = new EditBuffer();
|
|
23586
|
+
/** Received status card updates, applied at most once a second per message (WISP 4xx · Status Cards). */
|
|
23587
|
+
cardEdits = new CardEditPacer();
|
|
23588
|
+
/** When each message's buttons were last pressed here (`pressButton`). */
|
|
23589
|
+
pressedAt = /* @__PURE__ */ new Map();
|
|
22776
23590
|
/** At most one wake-up per contact per `WAKE_INTERVAL_MS` (WISP 401 § Wake-up push). */
|
|
22777
23591
|
wakeLimiter = new WakeLimiter();
|
|
22778
23592
|
/** Call wake-ups have their own, shorter limit: a call is rarer than a message and cannot wait five minutes. */
|
|
@@ -22794,6 +23608,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
22794
23608
|
storeMessage: (message) => this.storeMessage(message),
|
|
22795
23609
|
transfers: this.transfers,
|
|
22796
23610
|
changed: (delayMs) => this.emitState(delayMs),
|
|
23611
|
+
flush: () => this.flushState(),
|
|
22797
23612
|
settled: (linkId, fileId, record, seen) => {
|
|
22798
23613
|
if (seen && record.direction === "in" && record.state === "done") this.cueFeedback({
|
|
22799
23614
|
cue: "downloaded",
|
|
@@ -22804,6 +23619,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
22804
23619
|
});
|
|
22805
23620
|
stateTimer = null;
|
|
22806
23621
|
paymentTimer = null;
|
|
23622
|
+
/** Every minute, the reviews no Approve can send any more are cancelled (dropStaleReviews). */
|
|
23623
|
+
staleReviewTimer;
|
|
22807
23624
|
/** Group links: admins read knocks, joiners knock (WISP 9xx § Entry link). */
|
|
22808
23625
|
groupEntryTimer = null;
|
|
22809
23626
|
walletView = {
|
|
@@ -22812,6 +23629,34 @@ var GhostlyNode = class GhostlyNode {
|
|
|
22812
23629
|
history: [],
|
|
22813
23630
|
feesPaid: 0
|
|
22814
23631
|
};
|
|
23632
|
+
/** A new profile's default Mainnet wallets, made once in the background (`NodeOptions.defaultWallets`). */
|
|
23633
|
+
walletSetup = new WalletSetup({
|
|
23634
|
+
record: () => this.settings.walletSetup,
|
|
23635
|
+
save: async (record) => {
|
|
23636
|
+
this.settings = {
|
|
23637
|
+
...this.settings,
|
|
23638
|
+
walletSetup: record
|
|
23639
|
+
};
|
|
23640
|
+
await db.putSettings(this.settings);
|
|
23641
|
+
},
|
|
23642
|
+
offer: async (type) => {
|
|
23643
|
+
await this.refreshWallet();
|
|
23644
|
+
return this.walletView.offers?.find((o) => o.type === type && o.network === "mainnet");
|
|
23645
|
+
},
|
|
23646
|
+
create: async (type) => {
|
|
23647
|
+
await this.walletCreate({
|
|
23648
|
+
type,
|
|
23649
|
+
network: SETUP_NETWORK
|
|
23650
|
+
});
|
|
23651
|
+
},
|
|
23652
|
+
changed: () => {
|
|
23653
|
+
this.walletView = {
|
|
23654
|
+
...this.walletView,
|
|
23655
|
+
setup: this.walletSetup.view()
|
|
23656
|
+
};
|
|
23657
|
+
this.emitState();
|
|
23658
|
+
}
|
|
23659
|
+
});
|
|
22815
23660
|
/**
|
|
22816
23661
|
* Every wallet twice, one per network (real money, test coins), both open at once. Nothing is parked: a payment
|
|
22817
23662
|
* goes through the wallet of its own network, and a wallet of one network never pays for the other.
|
|
@@ -22890,7 +23735,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
22890
23735
|
method: "cashu",
|
|
22891
23736
|
prepare: (target, amount, feeCap) => new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).prepare(target, amount, feeCap),
|
|
22892
23737
|
execute: (review, prepared, persist) => new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).execute(review, prepared, persist),
|
|
22893
|
-
reconcile: (review, prepared
|
|
23738
|
+
reconcile: (review, prepared, persist) => {
|
|
23739
|
+
if (!persist) throw new Error("A Cashu payment is reconciled with its intent saved");
|
|
23740
|
+
return new CashuAdapter(this.wallet, (r, t) => this.desk.recordCashu(r, t)).reconcile(review, prepared, persist);
|
|
23741
|
+
}
|
|
22894
23742
|
}
|
|
22895
23743
|
], (review) => {
|
|
22896
23744
|
if (review.state === "settled") this.feedback("confirmed", review.id);
|
|
@@ -23411,8 +24259,13 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23411
24259
|
},
|
|
23412
24260
|
linkReady: (linkId, version = 1) => !!this.links.get(linkId)?.link?.supportsGroupVersion(version),
|
|
23413
24261
|
myNick: () => this.sharedNick,
|
|
23414
|
-
staysOnline: () => this.options.staysOnline ?? this.options.platform === "desktop",
|
|
24262
|
+
staysOnline: () => this.options.staysOnline ?? (this.options.platform === "desktop" && typeof RTCPeerConnection !== "undefined"),
|
|
23415
24263
|
peerRoom: (groupId) => {
|
|
24264
|
+
if (typeof RTCPeerConnection === "undefined" && Object.keys(this.nativeFactories).length) {
|
|
24265
|
+
let held = 0;
|
|
24266
|
+
for (const live of this.links.values()) if (live.stored.group && live.stored.group !== groupId && live.link) held++;
|
|
24267
|
+
return Math.max(0, 4 - held);
|
|
24268
|
+
}
|
|
23416
24269
|
const budget = this.options.peerBudget;
|
|
23417
24270
|
if (budget === void 0) return void 0;
|
|
23418
24271
|
let held = this.openChatPeers();
|
|
@@ -23432,7 +24285,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23432
24285
|
openEntry: (link, role, seedB64, peer) => this.openEntry(link, role, seedB64, peer),
|
|
23433
24286
|
linkSeen: (linkId) => {
|
|
23434
24287
|
const live = this.links.get(linkId);
|
|
23435
|
-
return
|
|
24288
|
+
return otherEndSeen(live?.presence, live?.dataLink);
|
|
24289
|
+
},
|
|
24290
|
+
linkBack: (linkId) => {
|
|
24291
|
+
const live = this.links.get(linkId);
|
|
24292
|
+
return !!live?.lastSyncAt && !!live.presence?.online && live.presence.lastPacketAt > live.lastSyncAt;
|
|
23436
24293
|
},
|
|
23437
24294
|
publish: (identity, records, background) => this.groupTransport.publish(identity, records, { background }),
|
|
23438
24295
|
resolve: async (pubKeyZ32, background, door) => (await this.groupTransport.resolve(pubKeyZ32, {
|
|
@@ -23449,9 +24306,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23449
24306
|
else this.closeGroupLink(linkId).catch(() => {});
|
|
23450
24307
|
},
|
|
23451
24308
|
edgeNick: (linkId) => this.links.get(linkId)?.presence.nick || void 0,
|
|
23452
|
-
storeMessage: (message) => this.
|
|
24309
|
+
storeMessage: (message) => this.storeNewMessage(message),
|
|
23453
24310
|
completeMessage: (message) => this.completeGroupMessage(message),
|
|
23454
24311
|
membersChanged: (groupId) => this.groupMembersChanged(groupId),
|
|
24312
|
+
historyGone: (groupId) => this.groupHistoryGone(groupId),
|
|
23455
24313
|
emit: () => this.emitState(),
|
|
23456
24314
|
communityApp: (groupId, sender, frame) => frame.t === "reaction" ? this.receiveGroupReaction(groupId, sender, frame) : frame.t === "edit" ? this.receiveCommunityEdit(groupId, sender, frame) : frame.t === "pin" ? this.receiveGroupPin(groupId, sender, readPin(frame)) : this.communityPay.receiveApp(groupId, sender, frame),
|
|
23457
24315
|
groupEdit: async (groupId, { sender, ...edit }) => {
|
|
@@ -23590,9 +24448,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23590
24448
|
* What each network's wallets still wait for (see `walletAwaiting`): open requests and invoices, paid invoices not
|
|
23591
24449
|
* claimed yet, ecash sent and not taken. Read from the Cashu quotes, the Lightning journals and the payments.
|
|
23592
24450
|
*/
|
|
24451
|
+
/** What each network's wallets still wait for, and each mint on its own (`mints`, only the mints that wait for something). */
|
|
23593
24452
|
async readAwaiting() {
|
|
23594
24453
|
const quotes = await this.wallet.quotes(), payments = this.desk.records(), now = Date.now();
|
|
23595
24454
|
const out = {};
|
|
24455
|
+
const mints = /* @__PURE__ */ new Map();
|
|
23596
24456
|
for (const network of WALLET_NETWORKS) {
|
|
23597
24457
|
const lightning = this.lightnings[network];
|
|
23598
24458
|
out[network] = walletAwaiting({
|
|
@@ -23606,8 +24466,27 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23606
24466
|
payments,
|
|
23607
24467
|
now
|
|
23608
24468
|
});
|
|
24469
|
+
for (const url of this.networkMints(network)) {
|
|
24470
|
+
const waits = mintAwaiting(url, {
|
|
24471
|
+
network,
|
|
24472
|
+
quotes,
|
|
24473
|
+
payments,
|
|
24474
|
+
now
|
|
24475
|
+
});
|
|
24476
|
+
if (waits.length) mints.set(url, waits);
|
|
24477
|
+
}
|
|
23609
24478
|
}
|
|
23610
|
-
return
|
|
24479
|
+
return {
|
|
24480
|
+
networks: out,
|
|
24481
|
+
mints
|
|
24482
|
+
};
|
|
24483
|
+
}
|
|
24484
|
+
/** A mint as the wallet page shows it, with what it still waits for when it does (asked about before it is removed). */
|
|
24485
|
+
withWaits(mints, waits) {
|
|
24486
|
+
return mints.map(({ awaiting: _, ...mint }) => waits.has(mint.url) ? {
|
|
24487
|
+
...mint,
|
|
24488
|
+
awaiting: waits.get(mint.url)
|
|
24489
|
+
} : mint);
|
|
23611
24490
|
}
|
|
23612
24491
|
awaitingTimer;
|
|
23613
24492
|
/** A payment changed (a request made, paid or closed): what the wallets wait for is read again, soon. */
|
|
@@ -23619,7 +24498,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23619
24498
|
if (this.shuttingDown || !networks) return;
|
|
23620
24499
|
for (const network of WALLET_NETWORKS) networks[network] = {
|
|
23621
24500
|
...networks[network],
|
|
23622
|
-
|
|
24501
|
+
mints: this.withWaits(networks[network].mints, awaiting.mints),
|
|
24502
|
+
awaiting: awaiting.networks[network]
|
|
23623
24503
|
};
|
|
23624
24504
|
this.walletView = {
|
|
23625
24505
|
...this.walletView,
|
|
@@ -23637,7 +24517,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23637
24517
|
everything = view.history;
|
|
23638
24518
|
const history = view.history.filter((tx) => !tx.mint || mintNetwork(tx.mint) === network);
|
|
23639
24519
|
networks[network] = {
|
|
23640
|
-
mints: view.mints,
|
|
24520
|
+
mints: this.withWaits(view.mints, awaiting.mints),
|
|
23641
24521
|
balance: view.balance,
|
|
23642
24522
|
history,
|
|
23643
24523
|
feesPaid: history.reduce((sum, tx) => sum + tx.fee, 0),
|
|
@@ -23649,7 +24529,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23649
24529
|
lightning: this.lightnings[network].view,
|
|
23650
24530
|
lightnings: this.lightnings[network].views(),
|
|
23651
24531
|
bitcoin: this.bitcoins[network].view,
|
|
23652
|
-
awaiting: awaiting[network]
|
|
24532
|
+
awaiting: awaiting.networks[network]
|
|
23653
24533
|
};
|
|
23654
24534
|
}
|
|
23655
24535
|
const wallets = walletInstances(networks);
|
|
@@ -23658,7 +24538,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23658
24538
|
networks,
|
|
23659
24539
|
wallets,
|
|
23660
24540
|
offers: this.walletOffers(networks, wallets),
|
|
23661
|
-
intents: (await intentRepository.list()).map((saved) => saved.review)
|
|
24541
|
+
intents: (await intentRepository.list()).map((saved) => saved.review),
|
|
24542
|
+
setup: this.walletSetup.view()
|
|
23662
24543
|
};
|
|
23663
24544
|
this.announcePaymentNetworks(wallets);
|
|
23664
24545
|
for (const tx of everything) {
|
|
@@ -23670,6 +24551,57 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23670
24551
|
}
|
|
23671
24552
|
}
|
|
23672
24553
|
this.walletFeedbackReady = true;
|
|
24554
|
+
await this.observeBackups();
|
|
24555
|
+
this.emitState();
|
|
24556
|
+
}
|
|
24557
|
+
/** Whether this profile's settings are read: before that, a reminder saved would write the defaults over them. */
|
|
24558
|
+
backupsLoaded = false;
|
|
24559
|
+
/** The backup reminders (shared/backupReminder): what the Mainnet balances change in them, saved when they do. */
|
|
24560
|
+
async observeBackups() {
|
|
24561
|
+
if (this.backupsLoaded) {
|
|
24562
|
+
const view = this.walletView;
|
|
24563
|
+
const { records, changed } = observeBalances(this.settings.backupReminders ?? {}, mainnetBalances(view.wallets ?? [], view.networks?.mainnet), Date.now());
|
|
24564
|
+
if (changed) await this.saveBackups(records);
|
|
24565
|
+
}
|
|
24566
|
+
this.walletView = {
|
|
24567
|
+
...this.walletView,
|
|
24568
|
+
backupReminders: this.settings.backupReminders ?? {}
|
|
24569
|
+
};
|
|
24570
|
+
}
|
|
24571
|
+
async saveBackups(records) {
|
|
24572
|
+
this.settings = {
|
|
24573
|
+
...this.settings,
|
|
24574
|
+
backupReminders: records
|
|
24575
|
+
};
|
|
24576
|
+
this.walletView = {
|
|
24577
|
+
...this.walletView,
|
|
24578
|
+
backupReminders: records
|
|
24579
|
+
};
|
|
24580
|
+
await db.putSettings(this.settings);
|
|
24581
|
+
}
|
|
24582
|
+
/** A wallet's phrase shown or its backup file made (once it answered): a copy of it exists, its reminder is over. */
|
|
24583
|
+
async copied(type, network, work) {
|
|
24584
|
+
const result = await work();
|
|
24585
|
+
const net = this.net(network);
|
|
24586
|
+
if (net === "mainnet" && this.backupsLoaded) {
|
|
24587
|
+
await this.saveBackups(markBackedUp(this.settings.backupReminders ?? {}, {
|
|
24588
|
+
kind: "phrase",
|
|
24589
|
+
id: `${type}:${net}`
|
|
24590
|
+
}, Date.now()));
|
|
24591
|
+
this.emitState();
|
|
24592
|
+
}
|
|
24593
|
+
return result;
|
|
24594
|
+
}
|
|
24595
|
+
/** The backup reminder's "Later" on one Mainnet wallet, or a profile backup just made (see `EngineApi`). */
|
|
24596
|
+
async walletBackupReminder(params) {
|
|
24597
|
+
const records = this.settings.backupReminders ?? {}, now = Date.now();
|
|
24598
|
+
if (params?.event === "profile") await this.saveBackups(markBackedUp(records, { kind: "profile" }, now));
|
|
24599
|
+
else if (params?.event === "later" && typeof params.wallet === "string") {
|
|
24600
|
+
const view = this.walletView;
|
|
24601
|
+
const held = mainnetBalances(view.wallets ?? [], view.networks?.mainnet).find((b) => b.id === params.wallet);
|
|
24602
|
+
if (!held) throw new Error("There is no such Mainnet wallet");
|
|
24603
|
+
await this.saveBackups(putOff(records, held.id, held.balance, now));
|
|
24604
|
+
} else throw new Error("Unknown backup reminder event");
|
|
23673
24605
|
this.emitState();
|
|
23674
24606
|
}
|
|
23675
24607
|
async pollPaymentStatus() {
|
|
@@ -23685,14 +24617,17 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23685
24617
|
}
|
|
23686
24618
|
}
|
|
23687
24619
|
async start() {
|
|
24620
|
+
const stored = await db.getSettings();
|
|
24621
|
+
const fresh = Object.keys(stored).length === 0;
|
|
23688
24622
|
this.settings = {
|
|
23689
24623
|
...DEFAULT_SETTINGS,
|
|
23690
|
-
...
|
|
24624
|
+
...stored
|
|
23691
24625
|
};
|
|
23692
24626
|
this.settings.relays = currentRelays(this.settings.relays);
|
|
23693
24627
|
this.relays?.setRelays(this.settings.relays);
|
|
23694
24628
|
this.configureDirect();
|
|
23695
24629
|
this.services = await db.getServices();
|
|
24630
|
+
this.backupsLoaded = true;
|
|
23696
24631
|
await this.identities.load();
|
|
23697
24632
|
this.identities.start();
|
|
23698
24633
|
await this.did.load();
|
|
@@ -23714,6 +24649,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23714
24649
|
await this.desk.start();
|
|
23715
24650
|
await this.refreshWallet();
|
|
23716
24651
|
this.wallet.start();
|
|
24652
|
+
this.dropStaleReviews();
|
|
24653
|
+
this.staleReviewTimer ??= setInterval(() => void this.dropStaleReviews(), 6e4);
|
|
23717
24654
|
this.stopWatchingAdapters ??= onAdaptersChanged(() => {
|
|
23718
24655
|
if (!this.shuttingDown) for (const network of WALLET_NETWORKS) {
|
|
23719
24656
|
this.lightnings[network].refreshOffered();
|
|
@@ -23724,7 +24661,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23724
24661
|
for (const stored of await db.getLinks()) {
|
|
23725
24662
|
const messages = stored.group ? [] : await db.getMessages(stored.id);
|
|
23726
24663
|
const storedFiles = stored.group ? [] : await fileStore.listForLink(stored.id);
|
|
23727
|
-
|
|
24664
|
+
const waiting = new Set(messages.flatMap((m) => m.sender === "me" && m.delivery === "waiting" && m.file ? [m.file.id] : []));
|
|
24665
|
+
for (const file of storedFiles) if (file.transfer && !file.wire3 && !waiting.has(file.id)) this.transfers.set(file.id, file.transfer.state === "transferring" ? {
|
|
23728
24666
|
...file.transfer,
|
|
23729
24667
|
state: "failed",
|
|
23730
24668
|
error: "Transfer interrupted. Retry when connected."
|
|
@@ -23737,13 +24675,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23737
24675
|
if (note) this.reactionNotes.set(stored.id, note);
|
|
23738
24676
|
if (stored.profile && !stored.group) await this.outboxFor(stored.id).recover();
|
|
23739
24677
|
}
|
|
24678
|
+
this.transfersRestored = true;
|
|
23740
24679
|
await this.groups.load();
|
|
23741
24680
|
for (const group of this.groups.views()) {
|
|
23742
24681
|
const note = latestReaction(await db.getMessages(`group:${group.id}`));
|
|
23743
24682
|
if (note) this.reactionNotes.set(`group:${group.id}`, note);
|
|
23744
24683
|
}
|
|
23745
24684
|
this.publicProfiles.start();
|
|
23746
|
-
if (this.settings.online) for (const
|
|
24685
|
+
if (this.settings.online) for (const linkId of this.nativeStartOrder([...history.keys()])) {
|
|
24686
|
+
const messages = history.get(linkId);
|
|
23747
24687
|
if (!this.links.has(linkId)) continue;
|
|
23748
24688
|
this.startLink(linkId, messages);
|
|
23749
24689
|
if (!this.links.get(linkId)?.stored.group) this.refreshPublicProfiles({ linkId }).catch(() => {});
|
|
@@ -23752,7 +24692,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23752
24692
|
if (this.settings.online) {
|
|
23753
24693
|
this.hold.start();
|
|
23754
24694
|
this.startGroupEntries();
|
|
23755
|
-
this.prepareSpare();
|
|
24695
|
+
this.prepareSpare(STARTUP_QUIET_MS);
|
|
23756
24696
|
}
|
|
23757
24697
|
this.pollPaymentStatus().catch(() => {});
|
|
23758
24698
|
Promise.all(WALLET_NETWORKS.map((n) => this.fedimintWallets[n].ensureReady())).then(() => this.desk.resumeFedimint());
|
|
@@ -23761,6 +24701,23 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23761
24701
|
this.bitcoins[network].ensureReady();
|
|
23762
24702
|
this.openWallets(network);
|
|
23763
24703
|
}
|
|
24704
|
+
this.startWalletSetup(fresh && !this.walletView.wallets?.length && this.links.size === 0).catch(() => {});
|
|
24705
|
+
}
|
|
24706
|
+
/** The first-run wallet setup, where the app runs it (`NodeOptions.defaultWallets`): begun once, then what is left. */
|
|
24707
|
+
async startWalletSetup(fresh) {
|
|
24708
|
+
const option = this.options.defaultWallets;
|
|
24709
|
+
if (!(typeof option === "function" ? await option() : option === true) || this.shuttingDown) return;
|
|
24710
|
+
await this.walletSetup.begin(fresh);
|
|
24711
|
+
}
|
|
24712
|
+
/** Wallet page: try again to make a default wallet the first-run setup could not make. */
|
|
24713
|
+
async walletSetupRetry({ type }) {
|
|
24714
|
+
if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
|
|
24715
|
+
await this.walletSetup.run(type);
|
|
24716
|
+
}
|
|
24717
|
+
/** Wallet page: the person does not want this default wallet made for them; it is not tried again. */
|
|
24718
|
+
async walletSetupDismiss({ type }) {
|
|
24719
|
+
if (!WALLET_TYPES.includes(type)) throw new Error("Unknown kind of wallet");
|
|
24720
|
+
await this.walletSetup.dismiss(type);
|
|
23764
24721
|
}
|
|
23765
24722
|
/** Opens the wallets of a network that exist. None is made here: wallets are made one at a time, with New. */
|
|
23766
24723
|
openWallets(network) {
|
|
@@ -23782,8 +24739,12 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23782
24739
|
this.shuttingDown = true;
|
|
23783
24740
|
this.depart();
|
|
23784
24741
|
if (this.relayRetry) clearTimeout(this.relayRetry);
|
|
24742
|
+
if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
|
|
24743
|
+
for (const timer of this.resumeTimers.values()) clearTimeout(timer);
|
|
24744
|
+
this.resumeTimers.clear();
|
|
23785
24745
|
if (this.paymentTimer) clearTimeout(this.paymentTimer);
|
|
23786
24746
|
clearTimeout(this.awaitingTimer);
|
|
24747
|
+
clearInterval(this.staleReviewTimer);
|
|
23787
24748
|
if (this.spareTimer) clearTimeout(this.spareTimer);
|
|
23788
24749
|
for (const timer of this.reactionTimers.values()) clearTimeout(timer);
|
|
23789
24750
|
this.reactionTimers.clear();
|
|
@@ -23810,11 +24771,28 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23810
24771
|
await Promise.allSettled([...this.outboxes.values()].map((outbox) => outbox.stop()));
|
|
23811
24772
|
for (const queue of this.editQueues.values()) queue.stop();
|
|
23812
24773
|
this.groupEdits.stop();
|
|
24774
|
+
this.cardEdits.stop();
|
|
23813
24775
|
await Promise.allSettled([...this.links.values()].map(async (live) => {
|
|
23814
24776
|
await live.link?.stop(true);
|
|
23815
24777
|
await live.caps?.stop();
|
|
23816
24778
|
}));
|
|
23817
24779
|
}
|
|
24780
|
+
/**
|
|
24781
|
+
* "Clear all data" (WISP 04), the peer's part. First what can be taken back from the network, best effort and within
|
|
24782
|
+
* `CLEAR_WITHDRAW_MS` in all: items held in storage for contacts, the DID (a `deactivated` record), the identity
|
|
24783
|
+
* proofs (their revocation records, once more). Then the peer stops, so nothing it holds in memory is written back,
|
|
24784
|
+
* and its database keeps only the wallets. The host starts a new peer afterwards (the page reloads).
|
|
24785
|
+
*/
|
|
24786
|
+
async clearProfileData() {
|
|
24787
|
+
const withdraw = Promise.allSettled([
|
|
24788
|
+
this.hold.withdrawAll(),
|
|
24789
|
+
this.did.deactivate(),
|
|
24790
|
+
this.identities.revokeAll()
|
|
24791
|
+
]);
|
|
24792
|
+
await Promise.race([withdraw, new Promise((resolve) => setTimeout(resolve, CLEAR_WITHDRAW_MS))]);
|
|
24793
|
+
await this.shutdown();
|
|
24794
|
+
await clearProfileStores();
|
|
24795
|
+
}
|
|
23818
24796
|
getState() {
|
|
23819
24797
|
const groups = this.groups.views();
|
|
23820
24798
|
return {
|
|
@@ -23826,7 +24804,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23826
24804
|
defaults: [...DEFAULT_IROH_RELAYS]
|
|
23827
24805
|
} } : {},
|
|
23828
24806
|
...this.transport.configure && { direct: true },
|
|
23829
|
-
...this.transport.discovery && { discovery: this.transport.discovery() }
|
|
24807
|
+
...this.transport.discovery && { discovery: this.transport.discovery() },
|
|
24808
|
+
...typeof RTCPeerConnection === "undefined" && { webrtc: false },
|
|
24809
|
+
...typeof RTCPeerConnection === "undefined" && !Object.keys(this.nativeFactories).length && { groupLinks: false }
|
|
23830
24810
|
},
|
|
23831
24811
|
links: [...this.links.values()].filter((live) => !live.stored.group).map((live) => this.viewOf(live)).sort((a, b) => b.createdAt - a.createdAt),
|
|
23832
24812
|
services: this.services.map((s) => ({
|
|
@@ -23834,6 +24814,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23834
24814
|
requests: this.requestCounts.get(s.id) ?? 0
|
|
23835
24815
|
})).sort((a, b) => a.createdAt - b.createdAt),
|
|
23836
24816
|
transfers: Object.fromEntries(this.transfers),
|
|
24817
|
+
transfersRestored: this.transfersRestored,
|
|
23837
24818
|
wallet: this.walletView,
|
|
23838
24819
|
payments: this.desk.views(),
|
|
23839
24820
|
identityProofs: this.identities.views(),
|
|
@@ -23883,7 +24864,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
23883
24864
|
}
|
|
23884
24865
|
/** The edge of a group toward one member, as the group page shows it: what carries it and when the member was last heard. */
|
|
23885
24866
|
edgeView(groupId, member) {
|
|
23886
|
-
for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer === member && !live.stored.groupEntry) return edgeView(live);
|
|
24867
|
+
for (const live of this.links.values()) if (live.stored.group === groupId && live.stored.groupPeer === member && !live.stored.groupEntry) return edgeView(live, Date.now(), this.groupNativeWaiting.has(live.stored.id));
|
|
23887
24868
|
}
|
|
23888
24869
|
getMessages(linkId) {
|
|
23889
24870
|
return db.getMessages(linkId);
|
|
@@ -24266,6 +25247,17 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24266
25247
|
if (members && holders.every((key) => members.has(key))) return;
|
|
24267
25248
|
this.rotateWake().then(() => this.emitState());
|
|
24268
25249
|
}
|
|
25250
|
+
/**
|
|
25251
|
+
* A group I left or forgot: its history is gone here, and the pages' copy goes too (an empty history, sent in order
|
|
25252
|
+
* with what came before). A page hears only what changes in a history it holds: a community left and joined again
|
|
25253
|
+
* by its link showed its old history under the new messages until a reload (bug hunt r5b, 2026-09-29).
|
|
25254
|
+
*/
|
|
25255
|
+
groupHistoryGone(groupId) {
|
|
25256
|
+
const linkId = `group:${groupId}`;
|
|
25257
|
+
this.newestAt.delete(linkId);
|
|
25258
|
+
this.reactionNotes.delete(linkId);
|
|
25259
|
+
this.events.onMessages(linkId, []);
|
|
25260
|
+
}
|
|
24269
25261
|
async setGroupWakeMuted(groupId, muted) {
|
|
24270
25262
|
const list = this.settings.wakeMutedGroups ?? [];
|
|
24271
25263
|
if (list.includes(groupId) === muted) return;
|
|
@@ -24502,8 +25494,22 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24502
25494
|
label: stored.label
|
|
24503
25495
|
}));
|
|
24504
25496
|
}
|
|
25497
|
+
/**
|
|
25498
|
+
* `card`: a bot's status card (WISP 4xx · Status Cards), checked here by the sender's rule; the text is its fallback,
|
|
25499
|
+
* written from the card when none is given. Only the headless runtime and SDKs send one: the app never offers it.
|
|
25500
|
+
*/
|
|
24505
25501
|
async sendMessage(params) {
|
|
24506
|
-
const { linkId
|
|
25502
|
+
const { linkId } = params;
|
|
25503
|
+
const card = params.card === void 0 ? void 0 : GhostlyNode.cardToSend(params.card);
|
|
25504
|
+
if (typeof card === "string") return {
|
|
25505
|
+
error: card,
|
|
25506
|
+
refused: true
|
|
25507
|
+
};
|
|
25508
|
+
const text = card && !(typeof params.text === "string" && params.text.trim()) ? statusCardText(card) : params.text;
|
|
25509
|
+
if (typeof text !== "string") return {
|
|
25510
|
+
error: "Nothing to send",
|
|
25511
|
+
refused: true
|
|
25512
|
+
};
|
|
24507
25513
|
const live = this.links.get(linkId);
|
|
24508
25514
|
const trimmed = text.trim();
|
|
24509
25515
|
const bytes = new TextEncoder().encode(trimmed).length;
|
|
@@ -24516,13 +25522,26 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24516
25522
|
const timestamp = params.timestamp ?? Date.now();
|
|
24517
25523
|
if (live.stored.profile) live.link.setTyping(false);
|
|
24518
25524
|
if (live.stored.profile) {
|
|
24519
|
-
const
|
|
24520
|
-
if (typeof
|
|
24521
|
-
error:
|
|
25525
|
+
const found = params.replyTo === void 0 ? void 0 : await this.replyFor(linkId, params.replyTo);
|
|
25526
|
+
if (typeof found === "string") return {
|
|
25527
|
+
error: found,
|
|
24522
25528
|
refused: true
|
|
24523
25529
|
};
|
|
25530
|
+
if (params.button !== void 0 && !(typeof params.button === "string" && BUTTON_ID.test(params.button) && found)) return {
|
|
25531
|
+
error: "No such button",
|
|
25532
|
+
refused: true
|
|
25533
|
+
};
|
|
25534
|
+
const reply = found && params.button !== void 0 ? {
|
|
25535
|
+
...found,
|
|
25536
|
+
button: params.button
|
|
25537
|
+
} : found;
|
|
25538
|
+
if (card) return this.sendChatText(live, trimmed, timestamp, void 0, reply, void 0, card);
|
|
24524
25539
|
return this.sendChatText(live, trimmed, timestamp, params.preview === void 0 ? void 0 : parseLinkPreview(params.preview, trimmed), reply);
|
|
24525
25540
|
}
|
|
25541
|
+
if (card) return {
|
|
25542
|
+
error: "Status cards need a current chat; this compatibility chat sends text only.",
|
|
25543
|
+
refused: true
|
|
25544
|
+
};
|
|
24526
25545
|
if (params.replyTo !== void 0) return {
|
|
24527
25546
|
error: "Replies need a current chat; this compatibility chat sends text only.",
|
|
24528
25547
|
refused: true
|
|
@@ -24568,19 +25587,27 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24568
25587
|
* both sides allow it; otherwise kept as `waiting` ("Sends when live") and sent by itself, in order, once
|
|
24569
25588
|
* the chat can carry it. Only what must never wait, or a security stop, is refused.
|
|
24570
25589
|
*/
|
|
24571
|
-
async sendChatText(live, trimmed, timestamp, preview, reply, forwarded) {
|
|
25590
|
+
async sendChatText(live, trimmed, timestamp, preview, reply, forwarded, card) {
|
|
24572
25591
|
const { link } = live, linkId = live.stored.id;
|
|
24573
25592
|
if (!link) return { error: "You are offline" };
|
|
24574
25593
|
const bytes = new TextEncoder().encode(trimmed).length;
|
|
24575
|
-
if (bytes > LIMITS.maxChatMessageBytes) return {
|
|
25594
|
+
if (bytes > LIMITS.maxChatMessageBytes) return {
|
|
25595
|
+
error: `Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`,
|
|
25596
|
+
refused: true
|
|
25597
|
+
};
|
|
24576
25598
|
const stop = this.chatStopped(live);
|
|
24577
25599
|
if (stop) return { error: stop };
|
|
25600
|
+
if (!link.isDataLinkOpen && link.dhtDelivery?.inviteTaken) return {
|
|
25601
|
+
error: INVITE_TAKEN,
|
|
25602
|
+
refused: true
|
|
25603
|
+
};
|
|
24578
25604
|
const wireId = toBase64Url(randomBytes(16)), id = `me_${wireId}`;
|
|
24579
25605
|
const delivery = link.isDataLinkOpen ? "stream" : link.textDelivery === "dht" ? "dht" : "unavailable";
|
|
24580
25606
|
if (delivery !== "stream") this.wakePeer(live);
|
|
24581
25607
|
const answers = {
|
|
24582
25608
|
...reply && { replyTo: reply },
|
|
24583
|
-
...forwarded && { forwarded }
|
|
25609
|
+
...forwarded && { forwarded },
|
|
25610
|
+
...card && { card }
|
|
24584
25611
|
};
|
|
24585
25612
|
if (delivery === "stream" || delivery === "dht" && bytes <= 256) {
|
|
24586
25613
|
const validationError = link.validateText(trimmed, timestamp, wireId, reply && pairedWireReply(reply));
|
|
@@ -24619,7 +25646,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24619
25646
|
via: "hold",
|
|
24620
25647
|
delivery: "sending",
|
|
24621
25648
|
...preview && { preview },
|
|
24622
|
-
...answers
|
|
25649
|
+
...answers,
|
|
25650
|
+
...card?.kind === "buttons" && { buttonsRestore: "due" }
|
|
24623
25651
|
});
|
|
24624
25652
|
this.hold.hold(linkId, {
|
|
24625
25653
|
kind: "text",
|
|
@@ -24673,15 +25701,22 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24673
25701
|
async resolveReply(message) {
|
|
24674
25702
|
const reply = message.replyTo;
|
|
24675
25703
|
if (!reply || reply.messageId) return message;
|
|
24676
|
-
const
|
|
25704
|
+
const history = await db.getMessages(message.linkId);
|
|
25705
|
+
const original = history.find((m) => m.id !== message.id && replyRef(m) === reply.id);
|
|
24677
25706
|
if (!original) return message;
|
|
24678
|
-
|
|
25707
|
+
const resolved = {
|
|
24679
25708
|
...message,
|
|
24680
25709
|
replyTo: {
|
|
24681
25710
|
...replyTo(original, reply.id),
|
|
24682
|
-
...reply.member && !original.member && { member: reply.member }
|
|
25711
|
+
...reply.member && !original.member && { member: reply.member },
|
|
25712
|
+
...reply.button && { button: reply.button }
|
|
24683
25713
|
}
|
|
24684
25714
|
};
|
|
25715
|
+
const press = buttonPress(resolved, original, history);
|
|
25716
|
+
return press ? {
|
|
25717
|
+
...resolved,
|
|
25718
|
+
press
|
|
25719
|
+
} : resolved;
|
|
24685
25720
|
}
|
|
24686
25721
|
/** A paired row's reply as it goes on the wire again (a resend, a hold). */
|
|
24687
25722
|
/** `localStorage["ghostly-test-no-edit"]`: this app offers no edit/1, as an older one (the e2e's old contact). Nothing else reads it. */
|
|
@@ -24695,6 +25730,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24695
25730
|
static wireReply(message) {
|
|
24696
25731
|
return message.replyTo && pairedWireReply(message.replyTo);
|
|
24697
25732
|
}
|
|
25733
|
+
/** The chat is on the DHT by choice: this side's DHT only, or the contact's (its records say so). It goes live for neither. */
|
|
25734
|
+
static dhtByChoice(live) {
|
|
25735
|
+
return live?.stored.deliveryMode === "dht" || live?.link?.dhtDelivery?.peerMode === "dht";
|
|
25736
|
+
}
|
|
25737
|
+
/** A status card a caller asks to send, by the sender's rule (WISP 4xx · Status Cards), or why it cannot go. */
|
|
25738
|
+
static cardToSend(raw) {
|
|
25739
|
+
const checked = checkStatusCard(raw);
|
|
25740
|
+
return "error" in checked ? `Status card: ${checked.error}` : checked.card;
|
|
25741
|
+
}
|
|
24698
25742
|
/**
|
|
24699
25743
|
* A chat stopped by a security rejection (a stream authenticated a participation key other than the pinned one):
|
|
24700
25744
|
* nothing goes until the person acts. Another key on the DHT or the link's signals is ignored instead (WISP 400).
|
|
@@ -24714,7 +25758,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24714
25758
|
error,
|
|
24715
25759
|
refused: true
|
|
24716
25760
|
});
|
|
24717
|
-
|
|
25761
|
+
const card = params.card === void 0 ? void 0 : GhostlyNode.cardToSend(params.card);
|
|
25762
|
+
if (typeof card === "string") return refuse(card);
|
|
25763
|
+
if (card && !(typeof params.text === "string" && params.text.trim())) params = {
|
|
25764
|
+
...params,
|
|
25765
|
+
text: statusCardText(card)
|
|
25766
|
+
};
|
|
25767
|
+
if (typeof linkId === "string" && linkId.startsWith("group:")) {
|
|
25768
|
+
const mentions = Array.isArray(params.mentions) ? params.mentions : [];
|
|
25769
|
+
return card ? this.groupEdits.edit(linkId.slice(6), params.messageId, params.text, mentions, card) : this.groupEdits.edit(linkId.slice(6), params.messageId, params.text, mentions);
|
|
25770
|
+
}
|
|
24718
25771
|
const live = this.links.get(linkId);
|
|
24719
25772
|
if (!live) return refuse("No such chat");
|
|
24720
25773
|
if (!live.stored.profile) return refuse("Editing needs a current chat; this compatibility chat cannot edit.");
|
|
@@ -24728,24 +25781,26 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24728
25781
|
if (new TextEncoder().encode(text).length > LIMITS.maxChatMessageBytes) return refuse(`Message exceeds ${LIMITS.maxChatMessageBytes} UTF-8 bytes.`);
|
|
24729
25782
|
const stop = this.chatStopped(live);
|
|
24730
25783
|
if (stop) return { error: stop };
|
|
24731
|
-
const preview = params.preview === void 0 ? void 0 : parseLinkPreview(params.preview, text);
|
|
24732
|
-
if (text === message.text && (preview?.u ?? "") === (message.preview?.u ?? "")) return {
|
|
25784
|
+
const preview = card || params.preview === void 0 ? void 0 : parseLinkPreview(params.preview, text);
|
|
25785
|
+
if (text === message.text && (preview?.u ?? "") === (message.preview?.u ?? "") && JSON.stringify(card ?? null) === JSON.stringify(message.card ?? null)) return {
|
|
24733
25786
|
error: null,
|
|
24734
25787
|
messageId: message.id
|
|
24735
25788
|
};
|
|
24736
|
-
const seq = (message.edit?.seq ?? 0) + 1;
|
|
24737
|
-
if (seq >
|
|
25789
|
+
const seq = (message.edit?.seq ?? 0) + 1, most = card ? STATUS_CARD_LIMITS.edits : 100;
|
|
25790
|
+
if (seq > most) return refuse(`This message was edited ${most} times, the most one takes.`);
|
|
24738
25791
|
const edited = withEdit(message, {
|
|
24739
25792
|
seq,
|
|
24740
25793
|
at: Date.now(),
|
|
24741
25794
|
text,
|
|
24742
25795
|
preview,
|
|
25796
|
+
card,
|
|
24743
25797
|
pending: true
|
|
24744
25798
|
});
|
|
24745
25799
|
await db.patchMessage(linkId, message.id, () => ({
|
|
24746
25800
|
text: edited.text,
|
|
24747
25801
|
edit: edited.edit,
|
|
24748
|
-
preview: edited.preview
|
|
25802
|
+
preview: edited.preview,
|
|
25803
|
+
card: edited.card
|
|
24749
25804
|
}));
|
|
24750
25805
|
await this.messagesChanged(linkId, [message.id]);
|
|
24751
25806
|
this.editsFor(linkId).flush().catch(() => {});
|
|
@@ -24772,21 +25827,30 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24772
25827
|
await this.applyPeerEdit(linkId, message, edit);
|
|
24773
25828
|
return true;
|
|
24774
25829
|
}
|
|
24775
|
-
/**
|
|
25830
|
+
/**
|
|
25831
|
+
* The contact's edit on its message here, when it is newer than what shows. Not a new message: no sound, no unread, no
|
|
25832
|
+
* move. A card's update is paced: at most one applied per message a second, the highest number winning.
|
|
25833
|
+
*/
|
|
24776
25834
|
async applyPeerEdit(linkId, message, edit) {
|
|
24777
25835
|
if (!takesPeerEdit(message, edit.m) || (message.edit?.seq ?? 0) >= edit.e) return;
|
|
25836
|
+
if (edit.sc) return this.cardEdits.take(`${linkId}\n${message.id}`, edit.e, () => this.applyPeerEditNow(linkId, message, edit));
|
|
25837
|
+
return this.applyPeerEditNow(linkId, message, edit);
|
|
25838
|
+
}
|
|
25839
|
+
async applyPeerEditNow(linkId, message, edit) {
|
|
24778
25840
|
if (await db.patchMessage(linkId, message.id, (current) => {
|
|
24779
25841
|
if (!takesPeerEdit(current, edit.m) || (current.edit?.seq ?? 0) >= edit.e) return null;
|
|
24780
25842
|
const next = withEdit(current, {
|
|
24781
25843
|
seq: edit.e,
|
|
24782
25844
|
at: receivedTimestamp(edit.ts),
|
|
24783
25845
|
text: edit.m,
|
|
24784
|
-
preview: edit.pv
|
|
25846
|
+
preview: edit.pv,
|
|
25847
|
+
card: edit.sc
|
|
24785
25848
|
});
|
|
24786
25849
|
return {
|
|
24787
25850
|
text: next.text,
|
|
24788
25851
|
edit: next.edit,
|
|
24789
|
-
preview: next.preview
|
|
25852
|
+
preview: next.preview,
|
|
25853
|
+
card: next.card
|
|
24790
25854
|
};
|
|
24791
25855
|
})) await this.messagesChanged(linkId, [message.id]);
|
|
24792
25856
|
}
|
|
@@ -24794,6 +25858,53 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24794
25858
|
takesDhtEdits(live) {
|
|
24795
25859
|
return !!live?.caps?.peer?.capabilities.includes(EDIT_CAPABILITY);
|
|
24796
25860
|
}
|
|
25861
|
+
/**
|
|
25862
|
+
* A question of mine with buttons went on the DHT floor or into a hold, which carry its text alone (WISP 4xx · Message
|
|
25863
|
+
* Buttons): its buttons are due to go again live. Once: a row that had them restored already stays so.
|
|
25864
|
+
*/
|
|
25865
|
+
async buttonsWentBare(linkId, message) {
|
|
25866
|
+
if (message.card?.kind !== "buttons" || message.buttonsRestore) return;
|
|
25867
|
+
await db.patchMessage(linkId, message.id, (current) => current.sender === "me" && current.card?.kind === "buttons" && !current.buttonsRestore ? { buttonsRestore: "due" } : null);
|
|
25868
|
+
}
|
|
25869
|
+
/**
|
|
25870
|
+
* Buttons the contact may not have (WISP 4xx · Message Buttons): a question whose text went on the DHT floor or into a
|
|
25871
|
+
* hold reached the contact as text alone, and a copy under the same id that comes live later is taken as the one
|
|
25872
|
+
* already there. Once the chat is live with an app that shows buttons (`buttons/1`) and takes edits, each such
|
|
25873
|
+
* question goes again as an edit of its buttons alone: the same text, so no version and no edit mark. Once per
|
|
25874
|
+
* message (the row says so, across restarts); the edit queue carries it (a card's edit goes only live, and a later
|
|
25875
|
+
* edit of the bot's replaces it, the highest number winning). An app without `buttons/1` (1.0.0 would mark it edited)
|
|
25876
|
+
* gets none: the row waits for one that shows buttons. The edit is marked `restore`: the bot's own event stream says
|
|
25877
|
+
* nothing of it, while its number counts, so a later edit of the bot's takes the next one.
|
|
25878
|
+
*/
|
|
25879
|
+
async restoreButtons(linkId) {
|
|
25880
|
+
const live = this.links.get(linkId), link = live?.link;
|
|
25881
|
+
if (!live?.stored.profile || live.stored.group || !link?.supportsEdits || !link.sessionOffers.peer?.includes("buttons/1")) return;
|
|
25882
|
+
const due = (await db.getMessages(linkId)).filter((m) => m.sender === "me" && m.buttonsRestore === "due");
|
|
25883
|
+
const changed = [];
|
|
25884
|
+
for (const message of due) {
|
|
25885
|
+
const at = Date.now();
|
|
25886
|
+
if ((await db.patchMessage(linkId, message.id, (current) => {
|
|
25887
|
+
if (current.sender !== "me" || current.buttonsRestore !== "due") return null;
|
|
25888
|
+
const seq = (current.edit?.seq ?? 0) + 1;
|
|
25889
|
+
if (current.card?.kind !== "buttons" || !current.wireId || seq > STATUS_CARD_LIMITS.edits) return { buttonsRestore: "sent" };
|
|
25890
|
+
return {
|
|
25891
|
+
edit: {
|
|
25892
|
+
...withEdit(current, {
|
|
25893
|
+
seq,
|
|
25894
|
+
at,
|
|
25895
|
+
text: current.text,
|
|
25896
|
+
preview: current.preview,
|
|
25897
|
+
card: current.card,
|
|
25898
|
+
pending: true
|
|
25899
|
+
}).edit,
|
|
25900
|
+
restore: true
|
|
25901
|
+
},
|
|
25902
|
+
buttonsRestore: "sent"
|
|
25903
|
+
};
|
|
25904
|
+
}))?.edit?.pending) changed.push(message.id);
|
|
25905
|
+
}
|
|
25906
|
+
if (changed.length) await this.messagesChanged(linkId, changed);
|
|
25907
|
+
}
|
|
24797
25908
|
/** My edits in one 1:1 chat on their way to the contact. */
|
|
24798
25909
|
editsFor(linkId) {
|
|
24799
25910
|
let queue = this.editQueues.get(linkId);
|
|
@@ -24891,8 +26002,9 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24891
26002
|
if (!link) return "You are offline. It is sent again once you are back.";
|
|
24892
26003
|
const at = Date.now(), snapshot = pathSnapshot(live, message.via);
|
|
24893
26004
|
const reply = GhostlyNode.wireReply(message);
|
|
24894
|
-
const error = message.forwarded ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded) : reply ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply) : await link.sendMessage(message.text, message.timestamp, message.wireId, ...message.preview ? [message.preview] : []);
|
|
26005
|
+
const error = message.card ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded, message.card) : message.forwarded ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply, message.forwarded) : reply ? await link.sendMessage(message.text, message.timestamp, message.wireId, message.preview, reply) : await link.sendMessage(message.text, message.timestamp, message.wireId, ...message.preview ? [message.preview] : []);
|
|
24895
26006
|
await this.noteTextSend(linkId, message, snapshot, at, error, link);
|
|
26007
|
+
if (!error && message.via === "pkarr") await this.buttonsWentBare(linkId, message);
|
|
24896
26008
|
return error;
|
|
24897
26009
|
}, (message) => message.via === "pkarr" ? DHT_MESSAGE_TTL : 2e4, (message) => {
|
|
24898
26010
|
const pending = this.links.get(linkId)?.stored.dhtDeliveryState?.pending;
|
|
@@ -24900,10 +26012,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24900
26012
|
}, { resender: {
|
|
24901
26013
|
ready: (message) => {
|
|
24902
26014
|
const live = this.links.get(linkId), link = live?.link;
|
|
24903
|
-
if (message.via === "pkarr" && live
|
|
26015
|
+
if (message.via === "pkarr" && !GhostlyNode.dhtByChoice(live) && link?.textDelivery !== "stream") return false;
|
|
24904
26016
|
return !!link?.canSendText && !!message.wireId && !link.validateText(message.text, message.timestamp, message.wireId, GhostlyNode.wireReply(message));
|
|
24905
26017
|
},
|
|
24906
|
-
requeueExpired: () => this.links.get(linkId)
|
|
26018
|
+
requeueExpired: () => !GhostlyNode.dhtByChoice(this.links.get(linkId)),
|
|
24907
26019
|
via: (message) => {
|
|
24908
26020
|
const delivery = this.links.get(linkId)?.link?.textDelivery;
|
|
24909
26021
|
return delivery === "dht" ? "pkarr" : delivery === "stream" ? "datalink" : message.via;
|
|
@@ -24915,6 +26027,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24915
26027
|
via: "hold",
|
|
24916
26028
|
resendUntil: void 0
|
|
24917
26029
|
});
|
|
26030
|
+
await this.buttonsWentBare(linkId, message);
|
|
24918
26031
|
await this.messagesChanged(linkId, [message.id]);
|
|
24919
26032
|
this.hold.hold(linkId, {
|
|
24920
26033
|
kind: "text",
|
|
@@ -24996,7 +26109,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
24996
26109
|
frame: "paired-message",
|
|
24997
26110
|
protocol: "chat/1",
|
|
24998
26111
|
plaintextBytes,
|
|
24999
|
-
wireBytes: utf8Encode(pairedMessageFrame(message.wireId ?? "", message.timestamp, message.text, message.preview, GhostlyNode.wireReply(message))).length
|
|
26112
|
+
wireBytes: utf8Encode(pairedMessageFrame(message.wireId ?? "", message.timestamp, message.text, message.preview, GhostlyNode.wireReply(message), message.forwarded, message.card)).length
|
|
25000
26113
|
};
|
|
25001
26114
|
return this.noteDetails(linkId, message.id, (details) => ({
|
|
25002
26115
|
...withSend(details, send),
|
|
@@ -25059,7 +26172,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25059
26172
|
frame: "paired-message",
|
|
25060
26173
|
protocol: "chat/1",
|
|
25061
26174
|
plaintextBytes,
|
|
25062
|
-
wireBytes: utf8Encode(pairedMessageFrame(message.id ?? "", message.timestamp, message.text, message.preview)).length
|
|
26175
|
+
wireBytes: utf8Encode(pairedMessageFrame(message.id ?? "", message.timestamp, message.text, message.preview, void 0, message.forwarded, message.card)).length
|
|
25063
26176
|
} };
|
|
25064
26177
|
}
|
|
25065
26178
|
/** A held item's fate, on its message: stored for the contact (with the item's place in the mailbox), failed, or picked up. */
|
|
@@ -25115,6 +26228,31 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25115
26228
|
completedAt: now,
|
|
25116
26229
|
...message.sender === "me" && { receiptAt: now }
|
|
25117
26230
|
});
|
|
26231
|
+
if (!error && message.sender === "me" && message.delivery !== "delivered") {
|
|
26232
|
+
if (await db.patchMessage(linkId, message.id, (m) => m.delivery === "delivered" ? null : {
|
|
26233
|
+
delivery: "delivered",
|
|
26234
|
+
deliveryError: void 0
|
|
26235
|
+
})) {
|
|
26236
|
+
await this.messagesChanged(linkId, [message.id]);
|
|
26237
|
+
this.emitState();
|
|
26238
|
+
}
|
|
26239
|
+
}
|
|
26240
|
+
}
|
|
26241
|
+
/**
|
|
26242
|
+
* The contact's app answered for a payment of mine (`pay-res`): its message gets two ticks, as a text gets from its
|
|
26243
|
+
* receipt. A payment goes with no delivery state of its own, so its mark read one tick for good. 1:1 chats only: a
|
|
26244
|
+
* group's payments live in the group's history, under other ids.
|
|
26245
|
+
*/
|
|
26246
|
+
async notePaymentReceipt(linkId, paymentId) {
|
|
26247
|
+
const message = (await db.getMessages(linkId)).find((m) => m.sender === "me" && m.paymentId === paymentId);
|
|
26248
|
+
if (!message || message.delivery === "delivered") return;
|
|
26249
|
+
if (await db.patchMessage(linkId, message.id, (m) => m.delivery === "delivered" ? null : {
|
|
26250
|
+
delivery: "delivered",
|
|
26251
|
+
deliveryError: void 0
|
|
26252
|
+
})) {
|
|
26253
|
+
await this.messagesChanged(linkId, [message.id]);
|
|
26254
|
+
this.emitState();
|
|
26255
|
+
}
|
|
25118
26256
|
}
|
|
25119
26257
|
deleteMessage({ linkId, messageId }) {
|
|
25120
26258
|
const live = this.links.get(linkId);
|
|
@@ -25395,6 +26533,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25395
26533
|
transferred: 0,
|
|
25396
26534
|
size: file.size
|
|
25397
26535
|
});
|
|
26536
|
+
this.flushState();
|
|
25398
26537
|
const first = `peer_${wire.timestamp}`, own = `${first}_${wire.id}`;
|
|
25399
26538
|
const messageIds = files.messageIds ??= /* @__PURE__ */ new Map();
|
|
25400
26539
|
const holder = messageIds.get(first);
|
|
@@ -25717,16 +26856,22 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25717
26856
|
if (!this.settings.online) throw new Error("Go online to join a group");
|
|
25718
26857
|
return { groupId: await this.groups.joinByLink(link) };
|
|
25719
26858
|
}
|
|
25720
|
-
async sendGroupMessage({ groupId, text, mentions, replyTo }) {
|
|
26859
|
+
async sendGroupMessage({ groupId, text: given, mentions, replyTo, card: raw, button }) {
|
|
26860
|
+
const card = raw === void 0 ? void 0 : GhostlyNode.cardToSend(raw);
|
|
26861
|
+
if (typeof card === "string") return { error: card };
|
|
26862
|
+
const text = card && !(typeof given === "string" && given.trim()) ? statusCardText(card) : given;
|
|
25721
26863
|
if (typeof text !== "string") return { error: "Nothing to send" };
|
|
25722
26864
|
const reply = replyTo === void 0 ? void 0 : await this.replyFor(`group:${groupId}`, replyTo);
|
|
25723
26865
|
if (typeof reply === "string") return { error: reply };
|
|
25724
26866
|
const named = Array.isArray(mentions) ? mentions : [];
|
|
25725
|
-
|
|
26867
|
+
if (button !== void 0 && !(typeof button === "string" && BUTTON_ID.test(button) && reply)) return { error: "No such button" };
|
|
26868
|
+
const wireReply = reply && {
|
|
25726
26869
|
i: reply.id,
|
|
25727
26870
|
s: reply.snippet,
|
|
25728
|
-
f: reply.member
|
|
25729
|
-
|
|
26871
|
+
f: reply.member,
|
|
26872
|
+
...button !== void 0 && { b: button }
|
|
26873
|
+
};
|
|
26874
|
+
const sent = card ? await this.groups.send(groupId, text, named, wireReply, void 0, card) : await this.groups.send(groupId, text, named, wireReply);
|
|
25730
26875
|
if (!sent.error) this.wakeMentioned(groupId, text, named);
|
|
25731
26876
|
return sent;
|
|
25732
26877
|
}
|
|
@@ -25734,6 +26879,59 @@ var GhostlyNode = class GhostlyNode {
|
|
|
25734
26879
|
return this.groups.messages(groupId);
|
|
25735
26880
|
}
|
|
25736
26881
|
/**
|
|
26882
|
+
* Presses a button of a message someone else sent to a chat or a group (`group:<id>`), WISP 4xx · Message Buttons:
|
|
26883
|
+
* a reply to it whose text is the button's label and whose `r` names the button, so the author learns who pressed
|
|
26884
|
+
* what, and an app without buttons reads an ordinary reply. Refused: my own message, a button it does not have,
|
|
26885
|
+
* buttons the author closed, a question I already answered with a `once` button, a second press within a second.
|
|
26886
|
+
*/
|
|
26887
|
+
async pressButton(params) {
|
|
26888
|
+
const { linkId, messageId, buttonId } = params ?? {};
|
|
26889
|
+
if (typeof linkId !== "string" || typeof messageId !== "string" || typeof buttonId !== "string") return {
|
|
26890
|
+
error: "No button to press",
|
|
26891
|
+
refused: true
|
|
26892
|
+
};
|
|
26893
|
+
const history = await db.getMessages(linkId);
|
|
26894
|
+
const message = history.find((m) => m.id === messageId);
|
|
26895
|
+
if (!message) return {
|
|
26896
|
+
error: "That message is not in this chat",
|
|
26897
|
+
refused: true
|
|
26898
|
+
};
|
|
26899
|
+
if (message.sender === "me") return {
|
|
26900
|
+
error: "Buttons on your own message do nothing",
|
|
26901
|
+
refused: true
|
|
26902
|
+
};
|
|
26903
|
+
const state = buttonsState(message, replyRef(message), history);
|
|
26904
|
+
const button = state?.card.buttons.find((b) => b.id === buttonId);
|
|
26905
|
+
if (!state || !button) return {
|
|
26906
|
+
error: "That message has no such button",
|
|
26907
|
+
refused: true
|
|
26908
|
+
};
|
|
26909
|
+
if (!state.open) return {
|
|
26910
|
+
error: state.card.closed ? "These buttons are closed" : "You already answered",
|
|
26911
|
+
refused: true
|
|
26912
|
+
};
|
|
26913
|
+
const key = `${linkId}\n${messageId}`, now = Date.now();
|
|
26914
|
+
if (now - (this.pressedAt.get(key) ?? -Infinity) < 1e3) return {
|
|
26915
|
+
error: "One press a second",
|
|
26916
|
+
refused: true,
|
|
26917
|
+
paced: true
|
|
26918
|
+
};
|
|
26919
|
+
if (this.pressedAt.size > 500) this.pressedAt.clear();
|
|
26920
|
+
this.pressedAt.set(key, now);
|
|
26921
|
+
if (linkId.startsWith("group:")) return this.sendGroupMessage({
|
|
26922
|
+
groupId: linkId.slice(6),
|
|
26923
|
+
text: button.label,
|
|
26924
|
+
replyTo: messageId,
|
|
26925
|
+
button: button.id
|
|
26926
|
+
});
|
|
26927
|
+
return this.sendMessage({
|
|
26928
|
+
linkId,
|
|
26929
|
+
text: button.label,
|
|
26930
|
+
replyTo: messageId,
|
|
26931
|
+
button: button.id
|
|
26932
|
+
});
|
|
26933
|
+
}
|
|
26934
|
+
/**
|
|
25737
26935
|
* Forwards messages of a chat or a group (`group:<id>`) to up to `FORWARD_TARGETS` others (WISP 400 § Forwards): each
|
|
25738
26936
|
* as a new message of mine, in the order they were written, carrying one more hop than it had and nothing of who wrote
|
|
25739
26937
|
* it or where. A text goes as written; a file from the bytes on this device (a copy per chat, nothing fetched again),
|
|
@@ -26158,8 +27356,19 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26158
27356
|
if (!live.link) throw new GhostlyHttpError("offline", "Ghostly is offline");
|
|
26159
27357
|
return live.link.request(serviceId, request);
|
|
26160
27358
|
}
|
|
26161
|
-
|
|
27359
|
+
/**
|
|
27360
|
+
* `network`: the Cashu wallet it is added from. A mint's network is its own (a test mint is Testnet's, any other
|
|
27361
|
+
* Mainnet's): one of the other network is refused, never filed into that network's wallet (which it would make,
|
|
27362
|
+
* unseen from the wallet it was added from). A test mint or one on this machine is known by its address, and is
|
|
27363
|
+
* refused from Mainnet before it is contacted. Any other address is Mainnet's only by default: from Testnet it is
|
|
27364
|
+
* contacted first, so a typo or a server that is not a mint is named as such, and it is called a mint of real sats
|
|
27365
|
+
* only once it answered as a mint.
|
|
27366
|
+
*/
|
|
27367
|
+
async walletAddMint({ url, primary, network }) {
|
|
27368
|
+
const other = !!network && mintNetwork(normalizeMintUrl(url)) !== network;
|
|
27369
|
+
if (other && network === "mainnet") throw engineError("testMintOnMainnet");
|
|
26162
27370
|
const mint = await this.wallet.checkMint(url);
|
|
27371
|
+
if (other) throw engineError("realMintOnTestnet");
|
|
26163
27372
|
const others = this.settings.mints.filter((m) => m !== mint.url);
|
|
26164
27373
|
const known = others.length !== this.settings.mints.length;
|
|
26165
27374
|
if (primary) await this.updateSettings({ settings: { mints: [mint.url, ...others] } });
|
|
@@ -26215,6 +27424,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26215
27424
|
await this.refreshWallet();
|
|
26216
27425
|
const made = this.walletView.wallets?.find((w) => w.type === type && w.network === network && (card === void 0 || w.card === card));
|
|
26217
27426
|
if (!made) throw new Error(`The ${label} wallet did not come up. Nothing was lost: try again.`);
|
|
27427
|
+
await this.walletSetup.made(type, network);
|
|
27428
|
+
this.walletView = {
|
|
27429
|
+
...this.walletView,
|
|
27430
|
+
setup: this.walletSetup.view()
|
|
27431
|
+
};
|
|
26218
27432
|
return made;
|
|
26219
27433
|
}
|
|
26220
27434
|
/**
|
|
@@ -26286,8 +27500,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26286
27500
|
await this.refreshWallet();
|
|
26287
27501
|
const card = type === "lightning" ? asked ?? this.lightnings[network].receivingId : void 0;
|
|
26288
27502
|
const lightningName = card !== void 0 ? this.walletView.networks?.[network].lightnings?.find((c) => c.card === card)?.name : void 0;
|
|
26289
|
-
const label = lightningName ? `${networkLabel(network)} Lightning card “${lightningName}”` : `${networkLabel(network)} ${WALLET_NAMES[type]}`;
|
|
26290
|
-
if (!this.walletView.wallets?.some((w) => w.type === type && w.network === network && (card === void 0 || w.card === card))) throw new Error(`There is no ${label}
|
|
27503
|
+
const label = lightningName ? `${networkLabel(network)} Lightning card “${lightningName}”` : `${networkLabel(network)} ${WALLET_NAMES[type]} wallet`;
|
|
27504
|
+
if (!this.walletView.wallets?.some((w) => w.type === type && w.network === network && (card === void 0 || w.card === card))) throw new Error(`There is no ${label} to remove`);
|
|
26291
27505
|
const first = walletRemoval(type, network, this.walletView.networks?.[network], this.walletView.intents, card);
|
|
26292
27506
|
if (first.comesWith) throw new Error(`Lightning through the Cashu mints comes with your ${networkLabel(network)} Cashu wallet: remove that wallet to remove it`);
|
|
26293
27507
|
if (first.awaiting.length) {
|
|
@@ -26297,7 +27511,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26297
27511
|
const removal = first.awaiting.length ? walletRemoval(type, network, this.walletView.networks?.[network], this.walletView.intents, card) : first;
|
|
26298
27512
|
if (removal.pending) throw new Error(`A payment through this wallet is not finished yet (${removal.pending}). Cancel it or wait for it to settle, then remove the wallet.`);
|
|
26299
27513
|
if (removalRisksFunds(removal) && acceptLoss !== true) throw new Error(lossRefusal(label, removal));
|
|
26300
|
-
for (const item of removal.awaiting) if (item.kind === "request" && item.paymentId) await this.closeRequest(item.paymentId, `you removed the ${label}
|
|
27514
|
+
for (const item of removal.awaiting) if (item.kind === "request" && item.paymentId) await this.closeRequest(item.paymentId, `you removed the ${label} it was paid to`).catch(() => {});
|
|
26301
27515
|
try {
|
|
26302
27516
|
if (type === "cashu") {
|
|
26303
27517
|
const mints = this.networkMints(network);
|
|
@@ -26314,6 +27528,11 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26314
27528
|
await this.refreshWallet();
|
|
26315
27529
|
}
|
|
26316
27530
|
if (!this.walletView.wallets?.some((w) => w.type === type && w.network === network)) await this.forgetChatNetwork(type, network);
|
|
27531
|
+
const reminders = this.settings.backupReminders;
|
|
27532
|
+
if (reminders && type !== "lightning" && `${type}:${network}` in reminders) {
|
|
27533
|
+
await this.saveBackups(forgetWallet(reminders, `${type}:${network}`));
|
|
27534
|
+
this.emitState();
|
|
27535
|
+
}
|
|
26317
27536
|
}
|
|
26318
27537
|
/**
|
|
26319
27538
|
* Before a wallet goes: its own rail is asked about what was paid to it (the mints about its quotes, the source about
|
|
@@ -26467,8 +27686,41 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26467
27686
|
await this.updateSettings({ settings: { mints: [url, ...this.settings.mints.filter((m) => m !== url)] } });
|
|
26468
27687
|
await this.refreshWallet();
|
|
26469
27688
|
}
|
|
26470
|
-
|
|
26471
|
-
|
|
27689
|
+
/**
|
|
27690
|
+
* Removes one mint of a Cashu wallet. Never one that holds sats, nor a network's last mint (that removes the Cashu
|
|
27691
|
+
* wallet: `walletRemove`). One that still waits for money (an invoice not paid yet, paid ecash not claimed) goes
|
|
27692
|
+
* only with `acceptLoss`: what is paid to it afterwards is claimed, but shows only once the mint is added again.
|
|
27693
|
+
*/
|
|
27694
|
+
async walletRemoveMint({ url, acceptLoss }) {
|
|
27695
|
+
const network = mintNetwork(url);
|
|
27696
|
+
const mints = this.networkMints(network);
|
|
27697
|
+
if (mints.length === 1 && mints[0] === url) throw engineError("lastMint", { network: networkLabel(network) });
|
|
27698
|
+
if (await this.wallet.balanceAt(url) > 0) throw engineError("mintHoldsSats");
|
|
27699
|
+
const waits = async () => mintAwaiting(url, {
|
|
27700
|
+
network,
|
|
27701
|
+
quotes: await this.wallet.quotes(),
|
|
27702
|
+
payments: this.desk.records(),
|
|
27703
|
+
now: Date.now()
|
|
27704
|
+
});
|
|
27705
|
+
if ((await waits()).length) {
|
|
27706
|
+
let timer;
|
|
27707
|
+
await Promise.race([this.wallet.checkQuotes([url]).catch(() => {}), new Promise((resolve) => {
|
|
27708
|
+
timer = setTimeout(resolve, removalTiming.claimMs);
|
|
27709
|
+
})]);
|
|
27710
|
+
clearTimeout(timer);
|
|
27711
|
+
if (await this.wallet.balanceAt(url) > 0) {
|
|
27712
|
+
await this.refreshWallet();
|
|
27713
|
+
throw engineError("mintHoldsSats");
|
|
27714
|
+
}
|
|
27715
|
+
const awaiting = await waits();
|
|
27716
|
+
if (awaiting.length && acceptLoss !== true) {
|
|
27717
|
+
const listed = awaiting.map((a) => {
|
|
27718
|
+
const text = ENGLISH_REMOVAL.item(a.kind, ENGLISH_REMOVAL.sats(a.amount, network));
|
|
27719
|
+
return text.charAt(0).toLowerCase() + text.slice(1);
|
|
27720
|
+
}).join("; ");
|
|
27721
|
+
throw new Error(`This mint still waits for money: ${listed}. Confirm that what is paid to it afterwards shows only once you add the mint again to remove it.`);
|
|
27722
|
+
}
|
|
27723
|
+
}
|
|
26472
27724
|
await this.updateSettings({ settings: { mints: this.settings.mints.filter((m) => m !== url) } });
|
|
26473
27725
|
await this.refreshWallet();
|
|
26474
27726
|
}
|
|
@@ -26480,6 +27732,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26480
27732
|
const n = this.net(network);
|
|
26481
27733
|
if (via === "cashu") {
|
|
26482
27734
|
const quote = await this.wallet.receiveLightning(amount, void 0, n);
|
|
27735
|
+
this.awaitingSoon();
|
|
26483
27736
|
return {
|
|
26484
27737
|
quote: quote.quote,
|
|
26485
27738
|
invoice: quote.invoice,
|
|
@@ -26488,6 +27741,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26488
27741
|
};
|
|
26489
27742
|
}
|
|
26490
27743
|
const created = await (await this.lightningCard(n, card)).createInvoice(amount);
|
|
27744
|
+
this.awaitingSoon();
|
|
26491
27745
|
return {
|
|
26492
27746
|
quote: created.paymentHash,
|
|
26493
27747
|
invoice: created.invoice,
|
|
@@ -26642,7 +27896,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26642
27896
|
return this.usdtWallets[this.net(params.network)].unlock(params.password);
|
|
26643
27897
|
}
|
|
26644
27898
|
usdtReveal(params) {
|
|
26645
|
-
return this.usdtWallets[this.net(params?.network)].reveal(params?.password);
|
|
27899
|
+
return this.copied("usdt", params?.network, () => this.usdtWallets[this.net(params?.network)].reveal(params?.password));
|
|
26646
27900
|
}
|
|
26647
27901
|
usdtLock(params) {
|
|
26648
27902
|
return this.usdtWallets[this.net(params?.network)].lock();
|
|
@@ -26651,7 +27905,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26651
27905
|
return this.usdtWallets[this.net(params?.network)].refresh();
|
|
26652
27906
|
}
|
|
26653
27907
|
usdtExportBackup(params) {
|
|
26654
|
-
return this.usdtWallets[this.net(params.network)].exportBackup(params.password);
|
|
27908
|
+
return this.copied("usdt", params.network, () => this.usdtWallets[this.net(params.network)].exportBackup(params.password));
|
|
26655
27909
|
}
|
|
26656
27910
|
async usdtRestoreBackup(params) {
|
|
26657
27911
|
const { wallet } = await this.restoreInto(this.usdtWallets, params.network, (w) => w.restoreBackup(params.text, params.password));
|
|
@@ -26667,10 +27921,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26667
27921
|
return this.arkWallets[this.net(params?.network)].lock();
|
|
26668
27922
|
}
|
|
26669
27923
|
arkBackup(params) {
|
|
26670
|
-
return this.arkWallets[this.net(params?.network)].backup(params?.password);
|
|
27924
|
+
return this.copied("arkade", params?.network, () => this.arkWallets[this.net(params?.network)].backup(params?.password));
|
|
26671
27925
|
}
|
|
26672
27926
|
arkExportBackup(params) {
|
|
26673
|
-
return this.arkWallets[this.net(params.network)].exportBackup(params.password);
|
|
27927
|
+
return this.copied("arkade", params.network, () => this.arkWallets[this.net(params.network)].exportBackup(params.password));
|
|
26674
27928
|
}
|
|
26675
27929
|
async arkRestoreBackup(params) {
|
|
26676
27930
|
const { wallet } = await this.restoreInto(this.arkWallets, params.network, (w) => w.restoreBackup(params.text, params.password));
|
|
@@ -26686,10 +27940,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26686
27940
|
return this.barkWallets[barkMode(params.network)].create(params);
|
|
26687
27941
|
}
|
|
26688
27942
|
barkBackup(params) {
|
|
26689
|
-
return this.barkWallets[this.net(params?.network)].backup();
|
|
27943
|
+
return this.copied("bark", params?.network, () => this.barkWallets[this.net(params?.network)].backup());
|
|
26690
27944
|
}
|
|
26691
27945
|
barkExportBackup(params) {
|
|
26692
|
-
return this.barkWallets[this.net(params.network)].exportBackup(params.password);
|
|
27946
|
+
return this.copied("bark", params.network, () => this.barkWallets[this.net(params.network)].exportBackup(params.password));
|
|
26693
27947
|
}
|
|
26694
27948
|
async barkRestoreBackup(params) {
|
|
26695
27949
|
const { wallet } = await this.restoreInto(this.barkWallets, params.network, (w) => w.restoreBackup(params.text, params.password));
|
|
@@ -26742,10 +27996,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26742
27996
|
return (this.fedimintOf(params.federation) ?? this.fedimintWallets.mainnet).takeBack(params.federation, params.operation);
|
|
26743
27997
|
}
|
|
26744
27998
|
fedimintBackup(params) {
|
|
26745
|
-
return this.fedimintWallets[this.net(params?.network)].backup();
|
|
27999
|
+
return this.copied("fedimint", params?.network, () => this.fedimintWallets[this.net(params?.network)].backup());
|
|
26746
28000
|
}
|
|
26747
28001
|
fedimintExportBackup(params) {
|
|
26748
|
-
return this.fedimintWallets[this.net(params.network)].exportBackup(params.password);
|
|
28002
|
+
return this.copied("fedimint", params.network, () => this.fedimintWallets[this.net(params.network)].exportBackup(params.password));
|
|
26749
28003
|
}
|
|
26750
28004
|
async fedimintRestoreBackup(params) {
|
|
26751
28005
|
return (await this.restoreInto(this.fedimintWallets, params.network, (w) => w.restoreBackup(params.text, params.password))).result;
|
|
@@ -26758,14 +28012,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26758
28012
|
await this.sparkWallets[sparkMode(params.network)].create(params);
|
|
26759
28013
|
}
|
|
26760
28014
|
async sparkBackup(params) {
|
|
26761
|
-
const { mnemonic, network } = await this.sparkWallets[this.net(params?.network)].backup();
|
|
28015
|
+
const { mnemonic, network } = await this.copied("spark", params?.network, () => this.sparkWallets[this.net(params?.network)].backup());
|
|
26762
28016
|
return {
|
|
26763
28017
|
mnemonic,
|
|
26764
28018
|
network
|
|
26765
28019
|
};
|
|
26766
28020
|
}
|
|
26767
28021
|
sparkExportBackup(params) {
|
|
26768
|
-
return this.sparkWallets[this.net(params.network)].exportBackup(params.password);
|
|
28022
|
+
return this.copied("spark", params.network, () => this.sparkWallets[this.net(params.network)].exportBackup(params.password));
|
|
26769
28023
|
}
|
|
26770
28024
|
async sparkRestoreBackup(params) {
|
|
26771
28025
|
const { wallet } = await this.restoreInto(this.sparkWallets, params.network, (w) => w.restoreBackup(params.text, params.password, params.apiKey));
|
|
@@ -26810,7 +28064,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26810
28064
|
if (params.requestId) {
|
|
26811
28065
|
if (!request || request.linkId !== params.linkId || request.direction !== "in" || request.state !== "pending" || request.amount !== params.amount) throw new Error("Payment review does not match the authenticated request");
|
|
26812
28066
|
if (params.target.method !== "cashu" ? JSON.stringify(request.target) !== JSON.stringify(params.target) : !!request.target || params.target.address !== request.id || !request.mints?.includes(params.target.provider)) throw new Error("Selected method or mint does not match the authenticated request");
|
|
26813
|
-
if (request.lightningPending || Object.values(this.desk.views()).some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && !["failed", "reclaimed"].includes(p.state))) throw
|
|
28067
|
+
if (request.lightningPending || Object.values(this.desk.views()).some((p) => p.kind === "payment" && p.requestId === request.id && p.linkId === params.linkId && !["failed", "reclaimed"].includes(p.state))) throw engineError("requestPaymentInFlight");
|
|
26814
28068
|
const asked = paymentNetwork(request);
|
|
26815
28069
|
if (asked !== paying) throw new Error(crossNetwork(paying, asked));
|
|
26816
28070
|
} else if (params.target.method !== "cashu" || !payee || params.target.address !== payee) throw new Error("Destination does not match the authenticated peer");
|
|
@@ -26843,18 +28097,18 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26843
28097
|
if (intent) assertConfirmedReal(intent.review.method === "cashu" ? mintNetwork(intent.review.provider) : walletNetworkOf(intent.review.network), params.confirmedReal);
|
|
26844
28098
|
if (intent?.review.linkId) {
|
|
26845
28099
|
const link = this.paymentLink(intent.review.linkId);
|
|
26846
|
-
if (!link || intent.review.method === "arkade" && !link.supportsArkPayments) throw
|
|
28100
|
+
if (!link || intent.review.method === "arkade" && !link.supportsArkPayments) throw engineError("reconnectBeforeApprove");
|
|
28101
|
+
if (intent.review.requestId) {
|
|
28102
|
+
const request = this.desk.payment(intent.review.requestId);
|
|
28103
|
+
if (!request || request.state !== "pending" || request.lightningPending) throw engineError("requestNotAwaiting");
|
|
28104
|
+
}
|
|
28105
|
+
await link.requirePaymentSupport();
|
|
26847
28106
|
if (intent.review.method === "usdt" && !link.supportsUsdtPayments) throw new Error("Reconnect a peer supporting USDT before approving");
|
|
26848
28107
|
if (intent.review.method === "bark" && !link.supportsBarkPayments) throw new Error("Reconnect a peer supporting Bark before approving");
|
|
26849
28108
|
if (intent.review.method === "bitcoin" && !link.supportsBitcoinPayments) throw new Error("Reconnect a peer taking on-chain Bitcoin before approving");
|
|
26850
28109
|
if (intent.review.method === "fedimint" && !link.supportsFedimintPayments) throw new Error("Reconnect a peer taking Fedimint before approving");
|
|
26851
28110
|
if (intent.review.method === "spark" && !link.supportsSparkPayments) throw new Error("Reconnect a peer taking Spark before approving");
|
|
26852
28111
|
if (intent.review.method === "cashu" && !link.allowsPayment("cashu")) throw new Error("Cashu is off in this chat");
|
|
26853
|
-
await link.requirePaymentSupport();
|
|
26854
|
-
if (intent.review.requestId) {
|
|
26855
|
-
const request = this.desk.payment(intent.review.requestId);
|
|
26856
|
-
if (!request || request.state !== "pending" || request.lightningPending) throw new Error("This request is no longer awaiting payment. Check its status before spending.");
|
|
26857
|
-
}
|
|
26858
28112
|
}
|
|
26859
28113
|
const review = await this.paymentCoordinator.approve(params.id);
|
|
26860
28114
|
await this.desk.confirmReviewedCashu(review);
|
|
@@ -26880,6 +28134,19 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26880
28134
|
cancelPayment(params) {
|
|
26881
28135
|
return this.paymentCoordinator.cancel(params.id);
|
|
26882
28136
|
}
|
|
28137
|
+
/**
|
|
28138
|
+
* Reviews left pending that no Approve can send: past their expiry (a direct send reviewed and never approved, say),
|
|
28139
|
+
* or for a request paid some other way or closed meanwhile. Cancelled as Cancel does, so nothing they reserved stays
|
|
28140
|
+
* held and they leave the wallet's list of payments to finish; each stays in the store as cancelled. A review
|
|
28141
|
+
* approved, submitted or of unknown outcome is never touched.
|
|
28142
|
+
*/
|
|
28143
|
+
async dropStaleReviews(now = Date.now()) {
|
|
28144
|
+
if (this.shuttingDown) return [];
|
|
28145
|
+
return this.paymentCoordinator.dropStale(now, (review) => {
|
|
28146
|
+
const request = review.requestId ? this.desk.payment(review.requestId) : void 0;
|
|
28147
|
+
return !!request && request.linkId === review.linkId && (request.state === "settled" || !!request.closed);
|
|
28148
|
+
}).catch(() => []);
|
|
28149
|
+
}
|
|
26883
28150
|
/** `network`: the Cashu card of that network sends (its mints). `confirmedReal`: required on Mainnet. */
|
|
26884
28151
|
sendPayment(params) {
|
|
26885
28152
|
const live = this.links.get(params.linkId);
|
|
@@ -26980,6 +28247,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
26980
28247
|
async updateSettings({ settings: patch }) {
|
|
26981
28248
|
const { nostr, ...rest } = patch;
|
|
26982
28249
|
const settings = rest;
|
|
28250
|
+
delete settings.backupReminders;
|
|
26983
28251
|
const wasOnline = this.settings.online;
|
|
26984
28252
|
for (const server of settings.iceServers ?? []) {
|
|
26985
28253
|
const problem = iceServerProblem(server);
|
|
@@ -27012,6 +28280,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27012
28280
|
delete settings.wakeRotate;
|
|
27013
28281
|
delete settings.wakeMutedGroups;
|
|
27014
28282
|
delete settings.wakeHeldBy;
|
|
28283
|
+
delete settings.walletSetup;
|
|
27015
28284
|
const relayBefore = this.hyperdhtRelay;
|
|
27016
28285
|
if (nostr) {
|
|
27017
28286
|
const current = effectiveNostrSettings(this.settings.nostr);
|
|
@@ -27094,7 +28363,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27094
28363
|
for (const live of this.links.values()) this.startLink(live.stored.id, await db.getMessages(live.stored.id));
|
|
27095
28364
|
this.hold.start();
|
|
27096
28365
|
this.startGroupEntries();
|
|
27097
|
-
this.prepareSpare();
|
|
28366
|
+
this.prepareSpare(STARTUP_QUIET_MS);
|
|
27098
28367
|
this.did.publishNow().catch(() => {});
|
|
27099
28368
|
}
|
|
27100
28369
|
if (wasOnline && !this.settings.online) this.stopGroupEntries();
|
|
@@ -27192,6 +28461,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27192
28461
|
if (!live?.stored.group) return;
|
|
27193
28462
|
this.links.delete(linkId);
|
|
27194
28463
|
this.groupWakeReceived.delete(linkId);
|
|
28464
|
+
this.groupNativeWaiting.delete(linkId);
|
|
28465
|
+
this.groupNativeRetryAt = 0;
|
|
27195
28466
|
if (live.stored.wakeToken && this.meshEdge(live.stored)) {
|
|
27196
28467
|
const group = live.stored.group, peer = live.stored.groupPeer;
|
|
27197
28468
|
if (!this.membership(group)?.members.has(peer)) this.rotateWake().then(() => this.emitState());
|
|
@@ -27210,6 +28481,24 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27210
28481
|
await live.caps?.stop();
|
|
27211
28482
|
await db.deleteLink(linkId);
|
|
27212
28483
|
}
|
|
28484
|
+
/**
|
|
28485
|
+
* An edge's session opened or ended: kept, so that an app starting again knows which edges were live when it quit
|
|
28486
|
+
* (`edgeLive`, `resume`). Nothing is written while the app shuts down: an edge live then was live when it quit.
|
|
28487
|
+
*/
|
|
28488
|
+
noteEdgeLive(linkId, up) {
|
|
28489
|
+
const live = this.links.get(linkId);
|
|
28490
|
+
if (!live || this.shuttingDown || !!live.stored.edgeLive === up) return;
|
|
28491
|
+
const since = up ? Date.now() : void 0;
|
|
28492
|
+
live.stored = {
|
|
28493
|
+
...live.stored,
|
|
28494
|
+
edgeLive: up || void 0,
|
|
28495
|
+
edgeLiveSince: since
|
|
28496
|
+
};
|
|
28497
|
+
db.patchLink(linkId, {
|
|
28498
|
+
edgeLive: up || void 0,
|
|
28499
|
+
edgeLiveSince: since
|
|
28500
|
+
}).catch(() => {});
|
|
28501
|
+
}
|
|
27213
28502
|
/** The edge of a group toward one member: a paired link pinned to that member's key, carrying group frames and nothing else. */
|
|
27214
28503
|
async openEdge(state, peer, expectPeer = false) {
|
|
27215
28504
|
const me = identityFromSeedB64(state.seedB64);
|
|
@@ -27278,7 +28567,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27278
28567
|
}
|
|
27279
28568
|
startGroupEntries() {
|
|
27280
28569
|
if (this.groupEntryTimer || this.shuttingDown || this.options.deferGroups) return;
|
|
27281
|
-
this.groupEntryTimer = setInterval(() =>
|
|
28570
|
+
this.groupEntryTimer = setInterval(() => {
|
|
28571
|
+
this.groups.tick().catch(() => {});
|
|
28572
|
+
this.retryGroupNative();
|
|
28573
|
+
}, 1e3);
|
|
27282
28574
|
}
|
|
27283
28575
|
stopGroupEntries() {
|
|
27284
28576
|
if (this.groupEntryTimer) clearInterval(this.groupEntryTimer);
|
|
@@ -27291,6 +28583,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27291
28583
|
const group = stored.group, peer = stored.groupPeer, entry = !!stored.groupEntry;
|
|
27292
28584
|
const role = stored.groupEntry ?? "edge";
|
|
27293
28585
|
let seen = false;
|
|
28586
|
+
const native = this.keepsGroupNative(stored);
|
|
28587
|
+
const resumeOn = native ? TRANSPORTS.find((t) => t !== "webrtc/1" && t in this.nativeFactories && !!stored.peerTransports?.includes(t)) : "webrtc/1";
|
|
27294
28588
|
live.pairing = { status: "connecting" };
|
|
27295
28589
|
live.link = new GhostLink({
|
|
27296
28590
|
paymentMethods: entry ? {
|
|
@@ -27308,6 +28602,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27308
28602
|
barkPaymentsSupport: !entry,
|
|
27309
28603
|
params: stored,
|
|
27310
28604
|
rtcAvailable: typeof RTCPeerConnection !== "undefined",
|
|
28605
|
+
resume: !entry && stored.edgeLive ? resumeOn : void 0,
|
|
28606
|
+
native: {
|
|
28607
|
+
peerDescriptors: stored.peerDescriptors,
|
|
28608
|
+
peerTransports: stored.peerTransports,
|
|
28609
|
+
peerFallback: stored.peerFallback,
|
|
28610
|
+
automatic: true
|
|
28611
|
+
},
|
|
28612
|
+
packetTransports: !this.options.webrtcGroupLinks,
|
|
28613
|
+
resumeFloor: !entry && stored.edgeLive ? stored.edgeLiveSince : void 0,
|
|
27311
28614
|
pairing: {
|
|
27312
28615
|
credentials: {
|
|
27313
28616
|
seedB64: stored.participationSeed,
|
|
@@ -27340,6 +28643,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27340
28643
|
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),
|
|
27341
28644
|
onGroupsSupport: (supported) => {
|
|
27342
28645
|
if (supported) traceJoin(group, "link.ready", { role });
|
|
28646
|
+
if (!entry) this.noteEdgeLive(linkId, supported);
|
|
27343
28647
|
if (supported) {
|
|
27344
28648
|
if (entry) this.groups.entryReady(group, linkId, peer);
|
|
27345
28649
|
else {
|
|
@@ -27352,6 +28656,19 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27352
28656
|
}
|
|
27353
28657
|
this.emitState();
|
|
27354
28658
|
},
|
|
28659
|
+
onPacketTransports: (peerTransports, peerDescriptors) => {
|
|
28660
|
+
const patch = {
|
|
28661
|
+
peerTransports,
|
|
28662
|
+
peerDescriptors,
|
|
28663
|
+
peerFallback: true
|
|
28664
|
+
};
|
|
28665
|
+
live.stored = {
|
|
28666
|
+
...live.stored,
|
|
28667
|
+
...patch
|
|
28668
|
+
};
|
|
28669
|
+
db.patchLink(linkId, patch).catch(() => {});
|
|
28670
|
+
if (this.keepsGroupNative(live.stored)) this.ensureNativeEndpoints(linkId);
|
|
28671
|
+
},
|
|
27355
28672
|
...entry ? {} : {
|
|
27356
28673
|
onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
|
|
27357
28674
|
onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
|
|
@@ -27397,6 +28714,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27397
28714
|
});
|
|
27398
28715
|
traceJoin(group, "link.start", { role });
|
|
27399
28716
|
live.link.start();
|
|
28717
|
+
if (native) this.ensureNativeEndpoints(linkId);
|
|
28718
|
+
}
|
|
28719
|
+
/**
|
|
28720
|
+
* Whether a group's link (an edge, an entry session) runs native endpoints (WISP 9xx § Transports): where this app has
|
|
28721
|
+
* no WebRTC (the Linux Desktop), and where the member's has none, as its packet said. Between two apps that have
|
|
28722
|
+
* WebRTC it runs none, as before: a group of eight would otherwise hold seven listeners per transport for nothing.
|
|
28723
|
+
*/
|
|
28724
|
+
keepsGroupNative(stored) {
|
|
28725
|
+
if (this.options.webrtcGroupLinks) return false;
|
|
28726
|
+
return typeof RTCPeerConnection === "undefined" || !!stored.peerTransports && !stored.peerTransports.includes("webrtc/1");
|
|
27400
28727
|
}
|
|
27401
28728
|
startLink(linkId, messages) {
|
|
27402
28729
|
const live = this.links.get(linkId);
|
|
@@ -27428,6 +28755,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27428
28755
|
reactionsSupport: this.options.reactions !== false,
|
|
27429
28756
|
editSupport: !GhostlyNode.testNoEdit(),
|
|
27430
28757
|
pinSupport: true,
|
|
28758
|
+
statusCardSupport: true,
|
|
28759
|
+
buttonsSupport: true,
|
|
27431
28760
|
wakeSupport: true,
|
|
27432
28761
|
servicesSupport: this.options.servicesSupport ?? (this.options.platform ?? "web") !== "web",
|
|
27433
28762
|
dht: stored.profile ? {
|
|
@@ -27439,6 +28768,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27439
28768
|
dhtDeliveryState: state
|
|
27440
28769
|
};
|
|
27441
28770
|
},
|
|
28771
|
+
firstControlAfterMs: STARTUP_QUIET_MS,
|
|
27442
28772
|
capsRev: () => live.caps?.rev,
|
|
27443
28773
|
peerCapsRev: (rev) => live.caps?.peerRev(rev),
|
|
27444
28774
|
peerAcceptsText: () => {
|
|
@@ -27456,6 +28786,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27456
28786
|
automatic: stored.preferredTransport === void 0
|
|
27457
28787
|
},
|
|
27458
28788
|
resume: stored.pairedPeerKey && stored.deliveryMode !== "dht" ? this.transportLogOf(live)?.liveAtLastRun : void 0,
|
|
28789
|
+
resumeFloor: stored.pairedPeerKey && stored.deliveryMode !== "dht" ? this.transportLogOf(live)?.liveSinceAtLastRun : void 0,
|
|
27459
28790
|
pairing: credentials ? {
|
|
27460
28791
|
credentials,
|
|
27461
28792
|
verifyPeer: async (key) => {
|
|
@@ -27545,7 +28876,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27545
28876
|
if (log.chose("contact", transport, Date.now())) this.saveTransportLog(live, log);
|
|
27546
28877
|
},
|
|
27547
28878
|
onTransportSwitched: () => {
|
|
27548
|
-
if (stored.profile && !stored.group)
|
|
28879
|
+
if (stored.profile && !stored.group) {
|
|
28880
|
+
this.editsFor(linkId).reopened();
|
|
28881
|
+
this.outboxFor(linkId).flush({ reopened: true }).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
|
|
28882
|
+
}
|
|
27549
28883
|
this.desk.replay(linkId).catch(() => {});
|
|
27550
28884
|
this.observeTransport(linkId);
|
|
27551
28885
|
},
|
|
@@ -27584,7 +28918,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27584
28918
|
live.pairing = state;
|
|
27585
28919
|
if (state.status === "ready") this.identities.ready(linkId);
|
|
27586
28920
|
else this.identities.closed(linkId);
|
|
27587
|
-
if (state.status === "ready" && live.link) this.hold.rememberPeerMethods(linkId, PAYMENT_METHODS.filter((m) => live.link.
|
|
28921
|
+
if (state.status === "ready" && live.link) this.hold.rememberPeerMethods(linkId, PAYMENT_METHODS.filter((m) => live.link.peerChoosesPayment(m))).catch(() => {});
|
|
27588
28922
|
live.proofs?.stop();
|
|
27589
28923
|
this.observeTransport(linkId);
|
|
27590
28924
|
this.emitState();
|
|
@@ -27610,7 +28944,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27610
28944
|
onMessageEdit: stored.profile && !stored.group ? (edit) => this.receiveEdit(linkId, edit) : void 0,
|
|
27611
28945
|
onEditReceipt: stored.profile && !stored.group ? (id, e) => this.editsFor(linkId).received(id, e) : void 0,
|
|
27612
28946
|
onEditSupport: (supported) => {
|
|
27613
|
-
if (supported && stored.profile && !stored.group) this.
|
|
28947
|
+
if (supported && stored.profile && !stored.group) this.restoreButtons(linkId).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
|
|
27614
28948
|
},
|
|
27615
28949
|
onWakeSupport: (supported) => {
|
|
27616
28950
|
if (supported && stored.profile && !stored.group) this.shareWake(linkId);
|
|
@@ -27644,7 +28978,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27644
28978
|
}
|
|
27645
28979
|
if (stored.profile && live.stored.deliveryMode !== "dht" && state !== "open") this.outboxFor(linkId).disconnected().catch(() => {});
|
|
27646
28980
|
if (state !== "open" && was === "open") live.caps?.refresh();
|
|
27647
|
-
if (stored.profile && !stored.group && state === "open")
|
|
28981
|
+
if (stored.profile && !stored.group && state === "open") {
|
|
28982
|
+
this.editsFor(linkId).reopened();
|
|
28983
|
+
this.outboxFor(linkId).flush({ reopened: true }).catch(() => {}).then(() => this.editsFor(linkId).flush()).catch(() => {});
|
|
28984
|
+
}
|
|
27648
28985
|
if (stored.profile && state !== "open" && live.pairing?.status !== "error") live.pairing = { status: "connecting" };
|
|
27649
28986
|
this.observeTransport(linkId);
|
|
27650
28987
|
this.emitState();
|
|
@@ -27697,7 +29034,8 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27697
29034
|
details: GhostlyNode.receivedTextDetails(!!stored.profile, message),
|
|
27698
29035
|
...message.preview && { preview: message.preview },
|
|
27699
29036
|
...stored.profile && message.reply && { replyTo: receivedPairedReply(message.reply) },
|
|
27700
|
-
...stored.profile && message.forwarded && { forwarded: message.forwarded }
|
|
29037
|
+
...stored.profile && message.forwarded && { forwarded: message.forwarded },
|
|
29038
|
+
...stored.profile && message.card && { card: message.card }
|
|
27701
29039
|
});
|
|
27702
29040
|
},
|
|
27703
29041
|
onCallSignal: (signal) => this.events.onCallSignal(linkId, signal),
|
|
@@ -27720,7 +29058,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27720
29058
|
onPaymentRequest: (request) => this.desk.onPaymentRequest(linkId, request),
|
|
27721
29059
|
onPaymentAsk: (ask) => this.desk.onPaymentAsk(linkId, ask),
|
|
27722
29060
|
onPayment: (payment) => this.desk.onPayment(linkId, payment),
|
|
27723
|
-
onPaymentResult: (result) =>
|
|
29061
|
+
onPaymentResult: async (result) => {
|
|
29062
|
+
await this.desk.onPaymentResult(linkId, result);
|
|
29063
|
+
this.notePaymentReceipt(linkId, result.id).catch(() => {});
|
|
29064
|
+
},
|
|
27724
29065
|
onFileStored: async (wire) => {
|
|
27725
29066
|
const stored = (await fileStore.listForLink(linkId)).find((file) => file.direction === "in" && file.wireId === wire.id);
|
|
27726
29067
|
if (!stored?.digest || !stored.metadata) return void 0;
|
|
@@ -27738,8 +29079,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27738
29079
|
const link = live.link;
|
|
27739
29080
|
link.start();
|
|
27740
29081
|
this.emitState();
|
|
29082
|
+
const resumed = stored.pairedPeerKey && stored.deliveryMode !== "dht" ? this.transportLogOf(live)?.liveAtLastRun : void 0;
|
|
29083
|
+
if (resumed) {
|
|
29084
|
+
traceLink(live.myPubKeyZ32, "resume", { transport: resumed });
|
|
29085
|
+
this.watchResume(linkId, link);
|
|
29086
|
+
}
|
|
29087
|
+
const nativeUp = stored.deliveryMode !== "dht" && this.keepsNativeEndpoints(linkId, stored) ? this.ensureNativeEndpoints(linkId) : void 0;
|
|
27741
29088
|
if (credentials && !stored.group) {
|
|
27742
|
-
live.caps = new CapsExchange({
|
|
29089
|
+
const caps = live.caps = new CapsExchange({
|
|
27743
29090
|
params: stored,
|
|
27744
29091
|
credentials,
|
|
27745
29092
|
transport: this.transport,
|
|
@@ -27755,14 +29102,21 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27755
29102
|
changed: (record) => this.peerCapsChanged(linkId, record),
|
|
27756
29103
|
published: () => live.link?.announceCapsRevision()
|
|
27757
29104
|
});
|
|
27758
|
-
|
|
27759
|
-
|
|
29105
|
+
if (stored.pairedPeerKey && nativeUp) {
|
|
29106
|
+
const late = setTimeout(() => caps.start(), STARTUP_QUIET_MS);
|
|
29107
|
+
const go = () => {
|
|
29108
|
+
clearTimeout(late);
|
|
29109
|
+
caps.start();
|
|
29110
|
+
};
|
|
29111
|
+
nativeUp.then(go, go);
|
|
29112
|
+
} else caps.start();
|
|
29113
|
+
const peer = caps.peer;
|
|
27760
29114
|
if (peer) {
|
|
27761
29115
|
link.learnPeerTransports(peer.transports.filter((t) => TRANSPORTS.includes(t)), dialDescriptors(peer.descriptors));
|
|
27762
29116
|
link.learnPeerChoice(peer.choice);
|
|
27763
29117
|
}
|
|
27764
29118
|
}
|
|
27765
|
-
|
|
29119
|
+
nativeUp?.then(() => {
|
|
27766
29120
|
if (live.link === link && stored.peerTransports && stored.preferredTransport !== "webrtc/1" && live.myPubKeyZ32 < stored.peerPubKeyZ32) link.connect().catch(() => {});
|
|
27767
29121
|
});
|
|
27768
29122
|
}
|
|
@@ -27837,7 +29191,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27837
29191
|
get nativeFactories() {
|
|
27838
29192
|
const relay = this.relaysHyperdht ? this.hyperdhtRelay : "";
|
|
27839
29193
|
return {
|
|
27840
|
-
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-
|
|
29194
|
+
...relay ? { "hyperdht/1": async (seedB64) => (await import("./hyperdhtRelay-_rJsllcP.js")).createRelayedHyperEndpoint(seedB64, relay) } : {},
|
|
27841
29195
|
...this.options.nativeTransports,
|
|
27842
29196
|
...this.options.irohWeb ? { "iroh/1": (seedB64) => createIrohWebEndpoint(seedB64, { relays: this.irohRelays }) } : {}
|
|
27843
29197
|
};
|
|
@@ -27864,6 +29218,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27864
29218
|
return !this.options.nativeTransports?.["hyperdht/1"];
|
|
27865
29219
|
}
|
|
27866
29220
|
/**
|
|
29221
|
+
* Whether a chat keeps native listeners: a contact's, the chat on screen, and one this side joined from a `ghostly1`
|
|
29222
|
+
* invite that is still pairing. The joiner dials, and without an endpoint of its own an app with no WebRTC (the Linux
|
|
29223
|
+
* Desktop) offered nothing the inviter could take: someone who went back to the chat list right after joining waited
|
|
29224
|
+
* out the connect (90 s) before the chat went live. An invite of this side's that nobody used yet keeps none.
|
|
29225
|
+
*/
|
|
29226
|
+
keepsNativeEndpoints(linkId, stored) {
|
|
29227
|
+
return !!stored.pairedPeerKey || !!stored.peerParticipationKeyZ32 || this.activeLinkId === linkId;
|
|
29228
|
+
}
|
|
29229
|
+
/**
|
|
27867
29230
|
* A relayed endpoint went away (the relay restarted, the network dropped): try again in a while, for every
|
|
27868
29231
|
* chat that keeps native listeners, as opening a chat would.
|
|
27869
29232
|
*/
|
|
@@ -27872,7 +29235,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27872
29235
|
if (!this.relaysHyperdht || !this.hyperdhtRelay || this.relayRetry || this.shuttingDown) return;
|
|
27873
29236
|
this.relayRetry = setTimeout(() => {
|
|
27874
29237
|
this.relayRetry = null;
|
|
27875
|
-
for (const [linkId, live] of this.links) if (live.link && live.stored.profile && !live.stored.group && live.stored.deliveryMode !== "dht" && !live.link.availableTransports.includes("hyperdht/1") && (live.stored
|
|
29238
|
+
for (const [linkId, live] of this.links) if (live.link && live.stored.profile && (!live.stored.group || this.keepsGroupNative(live.stored)) && live.stored.deliveryMode !== "dht" && !live.link.availableTransports.includes("hyperdht/1") && this.keepsNativeEndpoints(linkId, live.stored)) this.ensureNativeEndpoints(linkId);
|
|
27876
29239
|
}, RELAY_RETRY_MS);
|
|
27877
29240
|
}
|
|
27878
29241
|
/** A new relay (or none): chats give up their endpoints on the old one, as soon as none of them carries a session. */
|
|
@@ -27881,7 +29244,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27881
29244
|
for (const [linkId, live] of this.links) {
|
|
27882
29245
|
const link = live.link;
|
|
27883
29246
|
if (!link?.availableTransports.includes("hyperdht/1")) {
|
|
27884
|
-
if (this.hyperdhtRelay && link && (live.stored
|
|
29247
|
+
if (this.hyperdhtRelay && link && this.keepsNativeEndpoints(linkId, live.stored)) this.ensureNativeEndpoints(linkId);
|
|
27885
29248
|
continue;
|
|
27886
29249
|
}
|
|
27887
29250
|
if (!link.canReleaseEndpoint("hyperdht/1")) continue;
|
|
@@ -27895,19 +29258,34 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27895
29258
|
const expected = this.links.get(linkId)?.link;
|
|
27896
29259
|
const operation = this.nativeQueue.then(async () => {
|
|
27897
29260
|
const live = this.links.get(linkId), link = live?.link;
|
|
27898
|
-
if (this.shuttingDown || !live?.stored.profile || live.stored.
|
|
29261
|
+
if (this.shuttingDown || !live?.stored.profile || live.stored.deliveryMode === "dht" || !link || link !== expected) return;
|
|
29262
|
+
const group = !!live.stored.group;
|
|
29263
|
+
if (group && !this.keepsGroupNative(live.stored)) return;
|
|
29264
|
+
let full = false;
|
|
27899
29265
|
for (const [transport, factory] of Object.entries(this.nativeFactories)) {
|
|
27900
29266
|
const key = transport;
|
|
27901
29267
|
if (!factory || link.availableTransports.includes(key)) continue;
|
|
27902
29268
|
live.transportErrors ??= {};
|
|
27903
29269
|
try {
|
|
27904
29270
|
const owners = [...this.links.values()].filter((other) => other.link?.availableTransports.includes(key));
|
|
27905
|
-
if (owners.length >=
|
|
27906
|
-
|
|
27907
|
-
|
|
29271
|
+
if (group && (owners.length >= NATIVE_SLOTS || owners.filter((other) => other.stored.group).length >= 4)) {
|
|
29272
|
+
full = true;
|
|
29273
|
+
continue;
|
|
29274
|
+
}
|
|
29275
|
+
if (owners.length >= NATIVE_SLOTS) {
|
|
29276
|
+
const victim = owners.filter((other) => other.stored.group && other.link?.canReleaseEndpoint(key)).sort((a, b) => (a.link?.isDataLinkOpen ? 1 : 0) - (b.link?.isDataLinkOpen ? 1 : 0))[0] ?? (this.activeLinkId === linkId ? owners.filter((other) => other !== live && other.stored.id !== this.activeLinkId && other.link?.canReleaseEndpoint(key)).sort((a, b) => a.lastMessageAt - b.lastMessageAt)[0] : void 0);
|
|
29277
|
+
if (!victim) {
|
|
29278
|
+
traceLink(live.myPubKeyZ32, "native-no-slot", {
|
|
29279
|
+
transport: key,
|
|
29280
|
+
active: this.activeLinkId === linkId
|
|
29281
|
+
});
|
|
29282
|
+
if (this.activeLinkId === linkId && !group) this.retryActiveSlot(linkId);
|
|
29283
|
+
throw new Error("All eight native connection slots are in use. Disconnect a native connection in another chat, then reopen this chat or press Reconnect.");
|
|
29284
|
+
}
|
|
27908
29285
|
await victim.link.releaseEndpoint(key);
|
|
27909
29286
|
victim.transportErrors ??= {};
|
|
27910
29287
|
victim.transportErrors[key] = "Listener released for another chat. Open this chat to restore it; your messages and transport identity are saved.";
|
|
29288
|
+
if (victim.stored.group) this.groupNativeWaiting.add(victim.stored.id);
|
|
27911
29289
|
}
|
|
27912
29290
|
if (this.shuttingDown || live.link !== link) return;
|
|
27913
29291
|
const seed = live.stored.transportSeeds?.[key] ?? createIdentity().seedB64;
|
|
@@ -27932,12 +29310,95 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27932
29310
|
live.transportErrors[key] = error instanceof Error ? error.message : "Native adapter could not start. Reopen this chat to retry.";
|
|
27933
29311
|
}
|
|
27934
29312
|
}
|
|
29313
|
+
if (group) {
|
|
29314
|
+
if (full && !link.availableTransports.some((t) => t !== "webrtc/1")) this.groupNativeWaiting.add(linkId);
|
|
29315
|
+
else this.groupNativeWaiting.delete(linkId);
|
|
29316
|
+
}
|
|
27935
29317
|
this.emitState();
|
|
27936
29318
|
});
|
|
27937
29319
|
this.nativeQueue = operation.catch(() => {});
|
|
27938
29320
|
return operation;
|
|
27939
29321
|
}
|
|
27940
29322
|
/**
|
|
29323
|
+
* The order saved chats start in, which is the order they take native listeners in (`NATIVE_SLOTS` per transport):
|
|
29324
|
+
* the 1:1 chats written in, or live, most recently first; group links keep their places. In the order they were
|
|
29325
|
+
* stored, an app with more paired chats than slots left a chat that was live a moment before with no listener, and a
|
|
29326
|
+
* contact with no WebRTC (the Linux Desktop) never reached it again (Omarchy, 2026-09-30: "On DHT · retrying live" for
|
|
29327
|
+
* minutes after both apps restarted). Not "live when this app last ran" first: a chat that heard its contact leave as
|
|
29328
|
+
* both quit is not.
|
|
29329
|
+
*
|
|
29330
|
+
* On an app with WebRTC, the chats whose contact has none (its capability record lists no `webrtc/1`: a Linux Desktop,
|
|
29331
|
+
* a CLI with WebRTC off) come first, each class in that order: they reach this app over a native transport or not
|
|
29332
|
+
* live at all, while a contact with WebRTC still has it when no listener is left here.
|
|
29333
|
+
*/
|
|
29334
|
+
nativeStartOrder(linkIds) {
|
|
29335
|
+
const isChat = (id) => {
|
|
29336
|
+
const stored = this.links.get(id)?.stored;
|
|
29337
|
+
return !!stored && !stored.group;
|
|
29338
|
+
};
|
|
29339
|
+
const rtc = typeof RTCPeerConnection !== "undefined";
|
|
29340
|
+
const rank = new Map(linkIds.filter(isChat).map((id) => {
|
|
29341
|
+
const live = this.links.get(id);
|
|
29342
|
+
const theirs = live.stored.capsState?.peer?.transports ?? live.stored.peerTransports;
|
|
29343
|
+
return [id, {
|
|
29344
|
+
recent: Math.max(live.lastMessageAt, this.transportLogOf(live)?.lastLiveAt ?? 0),
|
|
29345
|
+
nativeOnly: rtc && !!theirs && !theirs.includes("webrtc/1")
|
|
29346
|
+
}];
|
|
29347
|
+
}));
|
|
29348
|
+
const chats = [...rank.keys()].sort((a, b) => {
|
|
29349
|
+
const x = rank.get(a), y = rank.get(b);
|
|
29350
|
+
return Number(y.nativeOnly) - Number(x.nativeOnly) || y.recent - x.recent;
|
|
29351
|
+
});
|
|
29352
|
+
chats.forEach((id, place) => traceLink(this.links.get(id).myPubKeyZ32, "native-order", {
|
|
29353
|
+
place,
|
|
29354
|
+
...rank.get(id)
|
|
29355
|
+
}));
|
|
29356
|
+
let next = 0;
|
|
29357
|
+
return linkIds.map((id) => rank.has(id) ? chats[next++] : id);
|
|
29358
|
+
}
|
|
29359
|
+
/**
|
|
29360
|
+
* A chat started as "live when this app last ran" (it resumes, and knocks): if this run is not live in it within
|
|
29361
|
+
* `RESUME_SPENT_MS`, its history says that stretch is over, so the next start does not resume it again.
|
|
29362
|
+
*/
|
|
29363
|
+
resumeTimers = /* @__PURE__ */ new Map();
|
|
29364
|
+
watchResume(linkId, link) {
|
|
29365
|
+
const before = this.resumeTimers.get(linkId);
|
|
29366
|
+
if (before) clearTimeout(before);
|
|
29367
|
+
this.resumeTimers.set(linkId, setTimeout(() => {
|
|
29368
|
+
this.resumeTimers.delete(linkId);
|
|
29369
|
+
const live = this.links.get(linkId), log = live && this.transportLogOf(live);
|
|
29370
|
+
if (this.shuttingDown || !live || !log || live.link !== link) return;
|
|
29371
|
+
if (!log.notBackAfterRestart(Date.now())) return;
|
|
29372
|
+
traceLink(live.myPubKeyZ32, "resume-spent", {});
|
|
29373
|
+
this.saveTransportLog(live, log);
|
|
29374
|
+
}, RESUME_SPENT_MS));
|
|
29375
|
+
}
|
|
29376
|
+
/** The chat on screen found every native slot busy: it tries again in a moment, while it stays on screen. */
|
|
29377
|
+
activeSlotRetry = null;
|
|
29378
|
+
retryActiveSlot(linkId) {
|
|
29379
|
+
if (this.activeSlotRetry) clearTimeout(this.activeSlotRetry);
|
|
29380
|
+
if (this.shuttingDown) return;
|
|
29381
|
+
this.activeSlotRetry = setTimeout(() => {
|
|
29382
|
+
this.activeSlotRetry = null;
|
|
29383
|
+
if (this.activeLinkId === linkId && !this.shuttingDown) this.ensureNativeEndpoints(linkId);
|
|
29384
|
+
}, ACTIVE_SLOT_RETRY_MS);
|
|
29385
|
+
}
|
|
29386
|
+
/** Group links waiting for a free native slot (`ensureNativeEndpoints`), by link id. */
|
|
29387
|
+
groupNativeWaiting = /* @__PURE__ */ new Set();
|
|
29388
|
+
groupNativeRetryAt = 0;
|
|
29389
|
+
/** Now and then, the group links that found no free native slot try again: a chat or another group may have let one go. */
|
|
29390
|
+
retryGroupNative() {
|
|
29391
|
+
if (!this.groupNativeWaiting.size || Date.now() < this.groupNativeRetryAt) return;
|
|
29392
|
+
this.groupNativeRetryAt = Date.now() + GROUP_NATIVE_RETRY_MS;
|
|
29393
|
+
for (const linkId of [...this.groupNativeWaiting]) {
|
|
29394
|
+
if (!this.links.get(linkId)?.link) {
|
|
29395
|
+
this.groupNativeWaiting.delete(linkId);
|
|
29396
|
+
continue;
|
|
29397
|
+
}
|
|
29398
|
+
this.ensureNativeEndpoints(linkId);
|
|
29399
|
+
}
|
|
29400
|
+
}
|
|
29401
|
+
/**
|
|
27941
29402
|
* A group message first stored from a copy a member handed on without its author's whole signature: what the whole
|
|
27942
29403
|
* copy adds (mentions, a reply, a hop count) joins the stored row; the text and time stay as they were.
|
|
27943
29404
|
*/
|
|
@@ -27948,10 +29409,16 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27948
29409
|
...whole.mentions && { mentions: whole.mentions },
|
|
27949
29410
|
...whole.mentioned && { mentioned: true },
|
|
27950
29411
|
...whole.replyTo && { replyTo: whole.replyTo },
|
|
27951
|
-
...whole.forwarded && { forwarded: whole.forwarded }
|
|
29412
|
+
...whole.forwarded && { forwarded: whole.forwarded },
|
|
29413
|
+
...whole.card && { card: whole.card },
|
|
29414
|
+
...whole.press && { press: whole.press }
|
|
27952
29415
|
};
|
|
27953
29416
|
if (!Object.keys(added).length) return;
|
|
27954
|
-
if (!await db.patchMessage(message.linkId, message.id, (stored) =>
|
|
29417
|
+
if (!await db.patchMessage(message.linkId, message.id, (stored) => {
|
|
29418
|
+
if (stored.member !== message.member || stored.sender !== message.sender) return null;
|
|
29419
|
+
const { card: _card, ...rest } = added;
|
|
29420
|
+
return stored.edit ? rest : added;
|
|
29421
|
+
})) return;
|
|
27955
29422
|
await this.messagesChanged(message.linkId, [message.id]);
|
|
27956
29423
|
this.emitState();
|
|
27957
29424
|
}
|
|
@@ -27960,6 +29427,15 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27960
29427
|
* the whole history. A host that takes whole histories only (no `onMessageChanges`) gets the whole history, as before.
|
|
27961
29428
|
*/
|
|
27962
29429
|
async messagesChanged(linkId, ids) {
|
|
29430
|
+
const reacted = this.reactionNotes.get(linkId);
|
|
29431
|
+
if (reacted?.message && ids.includes(reacted.message)) {
|
|
29432
|
+
const row = await db.getMessage(linkId, reacted.message);
|
|
29433
|
+
const fresh = row && noteAfterChange(reacted, row);
|
|
29434
|
+
if (fresh) {
|
|
29435
|
+
this.reactionNotes.set(linkId, fresh);
|
|
29436
|
+
this.emitState();
|
|
29437
|
+
}
|
|
29438
|
+
}
|
|
27963
29439
|
const onChanges = this.events.onMessageChanges;
|
|
27964
29440
|
if (!onChanges) return this.events.onMessages(linkId, await db.getMessages(linkId));
|
|
27965
29441
|
const unique = [...new Set(ids)];
|
|
@@ -27971,6 +29447,10 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27971
29447
|
});
|
|
27972
29448
|
}
|
|
27973
29449
|
async storeMessage(message) {
|
|
29450
|
+
await this.storeNewMessage(message);
|
|
29451
|
+
}
|
|
29452
|
+
/** Stores a message; false when it was there already (or deleted here): nothing new came. */
|
|
29453
|
+
async storeNewMessage(message) {
|
|
27974
29454
|
if (message.sender === "peer") message = {
|
|
27975
29455
|
...message,
|
|
27976
29456
|
timestamp: receivedTimestamp(message.timestamp)
|
|
@@ -27990,7 +29470,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
27990
29470
|
...message.sender === "peer" ? { member: pay.member } : {}
|
|
27991
29471
|
};
|
|
27992
29472
|
const live = this.links.get(message.linkId);
|
|
27993
|
-
if (live?.stored.deletedIds?.includes(message.id)) return;
|
|
29473
|
+
if (live?.stored.deletedIds?.includes(message.id)) return false;
|
|
27994
29474
|
message = await this.resolveReply(message);
|
|
27995
29475
|
if (message.sender === "peer" && live && !message.details?.received) message = {
|
|
27996
29476
|
...message,
|
|
@@ -28016,7 +29496,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28016
29496
|
}
|
|
28017
29497
|
};
|
|
28018
29498
|
}
|
|
28019
|
-
if (!await db.addMessage(message)) return;
|
|
29499
|
+
if (!await db.addMessage(message)) return false;
|
|
28020
29500
|
const newest = this.newestAt.get(message.linkId);
|
|
28021
29501
|
if (!message.event) this.newestAt.set(message.linkId, Math.max(newest ?? 0, message.timestamp));
|
|
28022
29502
|
if (message.sender === "peer") this.messageFeedback("message", message, newest);
|
|
@@ -28030,6 +29510,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28030
29510
|
this.emitState();
|
|
28031
29511
|
await this.reactions.stored(message);
|
|
28032
29512
|
await this.groupEdits.stored(message);
|
|
29513
|
+
return true;
|
|
28033
29514
|
}
|
|
28034
29515
|
servicesChanged() {
|
|
28035
29516
|
for (const live of this.links.values()) live.link?.refreshServices();
|
|
@@ -28071,6 +29552,7 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28071
29552
|
peerProofs: void 0,
|
|
28072
29553
|
proofError: void 0,
|
|
28073
29554
|
availableTransports: live.link?.availableTransports,
|
|
29555
|
+
runnableTransports: live.link ? this.runnableTransports(live) : void 0,
|
|
28074
29556
|
relayedTransports: live.link?.relayedTransports,
|
|
28075
29557
|
deliveryMode: live.stored.deliveryMode ?? "stream",
|
|
28076
29558
|
dhtDelivery: live.link?.dhtDelivery,
|
|
@@ -28139,6 +29621,14 @@ var GhostlyNode = class GhostlyNode {
|
|
|
28139
29621
|
this.events.onState(this.getState());
|
|
28140
29622
|
}, delayMs);
|
|
28141
29623
|
}
|
|
29624
|
+
/** The state now, not after `emitState`'s wait: for what the app must have before what follows it. */
|
|
29625
|
+
flushState() {
|
|
29626
|
+
if (this.stateTimer) {
|
|
29627
|
+
clearTimeout(this.stateTimer);
|
|
29628
|
+
this.stateTimer = null;
|
|
29629
|
+
}
|
|
29630
|
+
this.events.onState(this.getState());
|
|
29631
|
+
}
|
|
28142
29632
|
};
|
|
28143
29633
|
//#endregion
|
|
28144
29634
|
//#region ../browser/src/engine/server.ts
|
|
@@ -28154,6 +29644,11 @@ var EngineServer = class {
|
|
|
28154
29644
|
histories = /* @__PURE__ */ new WeakMap();
|
|
28155
29645
|
/** Per chat or group, what is still being sent (a whole history being read): what comes next waits for it. */
|
|
28156
29646
|
sending = /* @__PURE__ */ new Map();
|
|
29647
|
+
/**
|
|
29648
|
+
* Per chat, the contact's call offer that nothing here has answered yet. A client that attaches while it rings hears
|
|
29649
|
+
* it too: the extension's peer runs with no tab open, and a tab opened during the ring used to show no call.
|
|
29650
|
+
*/
|
|
29651
|
+
offers = /* @__PURE__ */ new Map();
|
|
28157
29652
|
constructor(options = {}) {
|
|
28158
29653
|
this.node = new GhostlyNode({
|
|
28159
29654
|
onAttention: (event) => this.broadcast({
|
|
@@ -28166,11 +29661,15 @@ var EngineServer = class {
|
|
|
28166
29661
|
}),
|
|
28167
29662
|
onMessages: (linkId, messages) => void this.inOrder(linkId, () => this.sendHistory(linkId, messages)).catch(() => {}),
|
|
28168
29663
|
onMessageChanges: (linkId, changes) => void this.inOrder(linkId, () => this.sendChanges(linkId, changes)).catch(() => {}),
|
|
28169
|
-
onCallSignal: (linkId, signal) =>
|
|
28170
|
-
|
|
28171
|
-
linkId
|
|
28172
|
-
|
|
28173
|
-
|
|
29664
|
+
onCallSignal: (linkId, signal) => {
|
|
29665
|
+
if (signalKind(signal) === "o") this.offers.set(linkId, signal);
|
|
29666
|
+
else this.offers.delete(linkId);
|
|
29667
|
+
this.broadcast({
|
|
29668
|
+
kind: "call-signal",
|
|
29669
|
+
linkId,
|
|
29670
|
+
signal
|
|
29671
|
+
});
|
|
29672
|
+
}
|
|
28174
29673
|
}, options);
|
|
28175
29674
|
this.ready = this.node.start();
|
|
28176
29675
|
}
|
|
@@ -28183,6 +29682,12 @@ var EngineServer = class {
|
|
|
28183
29682
|
kind: "state",
|
|
28184
29683
|
state: this.node.getState()
|
|
28185
29684
|
});
|
|
29685
|
+
for (const [linkId, signal] of this.offers) if (parseCallSignal(signal)?.t === "o") client.post({
|
|
29686
|
+
kind: "call-signal",
|
|
29687
|
+
linkId,
|
|
29688
|
+
signal
|
|
29689
|
+
});
|
|
29690
|
+
else this.offers.delete(linkId);
|
|
28186
29691
|
for (const link of this.node.getState().links) await this.inOrder(link.id, async () => {
|
|
28187
29692
|
if (!this.clients.has(client)) return;
|
|
28188
29693
|
client.post({
|
|
@@ -28207,6 +29712,7 @@ var EngineServer = class {
|
|
|
28207
29712
|
kind: "response",
|
|
28208
29713
|
id: request.id
|
|
28209
29714
|
};
|
|
29715
|
+
if (request.method === "setCallSignal") this.offers.delete(request.params?.linkId ?? "");
|
|
28210
29716
|
try {
|
|
28211
29717
|
await this.ready;
|
|
28212
29718
|
const method = this.node[request.method];
|
|
@@ -28287,7 +29793,15 @@ var EngineServer = class {
|
|
|
28287
29793
|
for (const client of [...this.clients]) this.post(client, event);
|
|
28288
29794
|
}
|
|
28289
29795
|
};
|
|
29796
|
+
/** A call signal's kind (`o` for an offer), read as the client reads it; checked in full before it is replayed. */
|
|
29797
|
+
function signalKind(signal) {
|
|
29798
|
+
try {
|
|
29799
|
+
return JSON.parse(signal)?.t;
|
|
29800
|
+
} catch {
|
|
29801
|
+
return;
|
|
29802
|
+
}
|
|
29803
|
+
}
|
|
28290
29804
|
//#endregion
|
|
28291
29805
|
export { EngineServer, hyperdhtRelayProblem as n, server_exports as t };
|
|
28292
29806
|
|
|
28293
|
-
//# sourceMappingURL=server-
|
|
29807
|
+
//# sourceMappingURL=server-C-8wbsAX.js.map
|