@arkade-os/sdk 0.4.18 → 0.4.20
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/dist/cjs/contracts/contractWatcher.js +7 -1
- package/dist/cjs/contracts/handlers/default.js +10 -3
- package/dist/cjs/contracts/handlers/helpers.js +47 -5
- package/dist/cjs/contracts/handlers/vhtlc.js +4 -2
- package/dist/cjs/identity/descriptor.js +98 -0
- package/dist/cjs/identity/descriptorProvider.js +2 -0
- package/dist/cjs/identity/index.js +15 -1
- package/dist/cjs/identity/seedIdentity.js +91 -6
- package/dist/cjs/identity/serialize.js +166 -0
- package/dist/cjs/identity/staticDescriptorProvider.js +65 -0
- package/dist/cjs/index.js +6 -3
- package/dist/cjs/providers/ark.js +45 -34
- package/dist/cjs/providers/electrum.js +663 -0
- package/dist/cjs/providers/indexer.js +5 -1
- package/dist/cjs/providers/utils.js +4 -0
- package/dist/cjs/wallet/ramps.js +1 -1
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +10 -0
- package/dist/cjs/wallet/serviceWorker/wallet.js +137 -91
- package/dist/cjs/wallet/vtxo-manager.js +133 -17
- package/dist/cjs/wallet/wallet.js +80 -19
- package/dist/cjs/worker/messageBus.js +200 -56
- package/dist/esm/contracts/contractWatcher.js +7 -1
- package/dist/esm/contracts/handlers/default.js +10 -3
- package/dist/esm/contracts/handlers/helpers.js +47 -5
- package/dist/esm/contracts/handlers/vhtlc.js +4 -2
- package/dist/esm/identity/descriptor.js +92 -0
- package/dist/esm/identity/descriptorProvider.js +1 -0
- package/dist/esm/identity/index.js +6 -1
- package/dist/esm/identity/seedIdentity.js +89 -6
- package/dist/esm/identity/serialize.js +159 -0
- package/dist/esm/identity/staticDescriptorProvider.js +61 -0
- package/dist/esm/index.js +2 -1
- package/dist/esm/providers/ark.js +46 -35
- package/dist/esm/providers/electrum.js +658 -0
- package/dist/esm/providers/indexer.js +6 -2
- package/dist/esm/providers/utils.js +3 -0
- package/dist/esm/wallet/ramps.js +1 -1
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +10 -0
- package/dist/esm/wallet/serviceWorker/wallet.js +137 -91
- package/dist/esm/wallet/vtxo-manager.js +133 -17
- package/dist/esm/wallet/wallet.js +80 -19
- package/dist/esm/worker/messageBus.js +201 -57
- package/dist/types/contracts/handlers/default.d.ts +1 -1
- package/dist/types/contracts/handlers/helpers.d.ts +1 -1
- package/dist/types/contracts/types.d.ts +11 -3
- package/dist/types/identity/descriptor.d.ts +35 -0
- package/dist/types/identity/descriptorProvider.d.ts +28 -0
- package/dist/types/identity/index.d.ts +7 -1
- package/dist/types/identity/seedIdentity.d.ts +41 -4
- package/dist/types/identity/serialize.d.ts +84 -0
- package/dist/types/identity/staticDescriptorProvider.d.ts +18 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/providers/electrum.d.ts +212 -0
- package/dist/types/providers/utils.d.ts +1 -0
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +11 -2
- package/dist/types/wallet/serviceWorker/wallet.d.ts +27 -10
- package/dist/types/wallet/vtxo-manager.d.ts +15 -0
- package/dist/types/wallet/wallet.d.ts +3 -0
- package/dist/types/worker/messageBus.d.ts +68 -8
- package/package.json +3 -2
|
@@ -63,6 +63,12 @@ export class ReadonlyWallet {
|
|
|
63
63
|
this.walletRepository = walletRepository;
|
|
64
64
|
this.contractRepository = contractRepository;
|
|
65
65
|
this.delegatorProvider = delegatorProvider;
|
|
66
|
+
// Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
|
|
67
|
+
// from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
|
|
68
|
+
// another send/settle racing the _txLock) can't reselect coins that are
|
|
69
|
+
// already on their way out. The set is in-memory only: a process crash
|
|
70
|
+
// clears it, and a stale entry only hides a VTXO (never spends one).
|
|
71
|
+
this._pendingSpendOutpoints = new Set();
|
|
66
72
|
// Guard: detect identity/server network mismatch for descriptor-based identities.
|
|
67
73
|
// This duplicates the check in setupWalletConfig() so that subclasses
|
|
68
74
|
// bypassing the factory still get the safety net.
|
|
@@ -303,6 +309,9 @@ export class ReadonlyWallet {
|
|
|
303
309
|
return vtxos
|
|
304
310
|
.flatMap((_) => _.vtxos)
|
|
305
311
|
.filter((vtxo) => {
|
|
312
|
+
if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
306
315
|
if (isSpendable(vtxo)) {
|
|
307
316
|
if (!f.withRecoverable &&
|
|
308
317
|
(isRecoverable(vtxo) || isExpired(vtxo))) {
|
|
@@ -754,6 +763,20 @@ export class ReadonlyWallet {
|
|
|
754
763
|
* ```
|
|
755
764
|
*/
|
|
756
765
|
export class Wallet extends ReadonlyWallet {
|
|
766
|
+
_addPendingSpends(inputs) {
|
|
767
|
+
for (const input of inputs) {
|
|
768
|
+
if ("virtualStatus" in input) {
|
|
769
|
+
this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
_removePendingSpends(inputs) {
|
|
774
|
+
for (const input of inputs) {
|
|
775
|
+
if ("virtualStatus" in input) {
|
|
776
|
+
this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
757
780
|
_withTxLock(fn) {
|
|
758
781
|
let release;
|
|
759
782
|
const lock = new Promise((r) => (release = r));
|
|
@@ -969,9 +992,15 @@ export class Wallet extends ReadonlyWallet {
|
|
|
969
992
|
amount: BigInt(selected.changeAmount),
|
|
970
993
|
});
|
|
971
994
|
}
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
995
|
+
this._addPendingSpends(selected.inputs);
|
|
996
|
+
try {
|
|
997
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
|
|
998
|
+
await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
|
|
999
|
+
return arkTxid;
|
|
1000
|
+
}
|
|
1001
|
+
finally {
|
|
1002
|
+
this._removePendingSpends(selected.inputs);
|
|
1003
|
+
}
|
|
975
1004
|
});
|
|
976
1005
|
}
|
|
977
1006
|
return this.send({
|
|
@@ -1017,7 +1046,8 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1017
1046
|
const tip = await this.onchainProvider.getChainTip();
|
|
1018
1047
|
chainTipHeight = tip.height;
|
|
1019
1048
|
}
|
|
1020
|
-
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) =>
|
|
1049
|
+
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
|
|
1050
|
+
!hasBoardingTxExpired(utxo, boardingTimelock, chainTipHeight));
|
|
1021
1051
|
const filteredBoardingUtxos = [];
|
|
1022
1052
|
for (const utxo of boardingUtxos) {
|
|
1023
1053
|
const inputFee = estimator.evalOnchainInput({
|
|
@@ -1041,10 +1071,10 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1041
1071
|
weight: 0,
|
|
1042
1072
|
birth: vtxo.createdAt,
|
|
1043
1073
|
expiry: vtxo.virtualStatus.batchExpiry
|
|
1044
|
-
? new Date(vtxo.virtualStatus.batchExpiry
|
|
1045
|
-
:
|
|
1074
|
+
? new Date(vtxo.virtualStatus.batchExpiry)
|
|
1075
|
+
: undefined,
|
|
1046
1076
|
});
|
|
1047
|
-
if (inputFee.
|
|
1077
|
+
if (inputFee.satoshis >= vtxo.value) {
|
|
1048
1078
|
// skip if fees are greater than the virtual output value
|
|
1049
1079
|
continue;
|
|
1050
1080
|
}
|
|
@@ -1152,11 +1182,29 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1152
1182
|
...params.inputs.map((input) => `${input.txid}:${input.vout}`),
|
|
1153
1183
|
];
|
|
1154
1184
|
const abortController = new AbortController();
|
|
1185
|
+
let stream;
|
|
1186
|
+
// Optimistically hide these inputs from concurrent getVtxos() callers
|
|
1187
|
+
// while the settlement is in flight. Set before safeRegisterIntent so
|
|
1188
|
+
// there's no window between intent registration and coin-visibility.
|
|
1189
|
+
this._addPendingSpends(params.inputs);
|
|
1155
1190
|
try {
|
|
1156
|
-
|
|
1191
|
+
stream = this.arkProvider.getEventStream(abortController.signal, topics);
|
|
1192
|
+
// Prime the iterator so the provider opens the SSE subscription
|
|
1193
|
+
// before safeRegisterIntent can trigger server-side batch events.
|
|
1194
|
+
const firstNext = stream.next();
|
|
1195
|
+
// If settle exits before Batch.join consumes the primed result,
|
|
1196
|
+
// keep the orphaned promise from surfacing as an unhandled rejection.
|
|
1197
|
+
void firstNext.catch(() => { });
|
|
1198
|
+
const primedStream = (async function* () {
|
|
1199
|
+
const first = await firstNext;
|
|
1200
|
+
if (!first.done) {
|
|
1201
|
+
yield first.value;
|
|
1202
|
+
}
|
|
1203
|
+
yield* stream;
|
|
1204
|
+
})();
|
|
1157
1205
|
const intentId = await this.safeRegisterIntent(intent, params.inputs);
|
|
1158
1206
|
const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
|
|
1159
|
-
const commitmentTxid = await Batch.join(
|
|
1207
|
+
const commitmentTxid = await Batch.join(primedStream, handler, {
|
|
1160
1208
|
abortController,
|
|
1161
1209
|
skipVtxoTreeSigning: !hasOffchainOutputs,
|
|
1162
1210
|
eventCallback: eventCallback
|
|
@@ -1180,23 +1228,28 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1180
1228
|
throw error;
|
|
1181
1229
|
}
|
|
1182
1230
|
finally {
|
|
1183
|
-
//
|
|
1231
|
+
// Clear state first so a synchronous handler firing from abort()
|
|
1232
|
+
// never observes a stale pending-spend set.
|
|
1233
|
+
this._removePendingSpends(params.inputs);
|
|
1234
|
+
// close the stream — abort() fires the in-body handler if the
|
|
1235
|
+
// generator has started iterating; return() also releases the
|
|
1236
|
+
// eager resource if the body is still suspended or never ran
|
|
1237
|
+
// (e.g. safeRegisterIntent threw before Batch.join was called).
|
|
1184
1238
|
abortController.abort();
|
|
1239
|
+
await stream?.return?.().catch(() => { });
|
|
1185
1240
|
}
|
|
1186
1241
|
}
|
|
1187
1242
|
async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
|
|
1188
1243
|
// the signed forfeits transactions to submit
|
|
1189
1244
|
const signedForfeits = [];
|
|
1190
|
-
const
|
|
1245
|
+
const isVtxo = (input) => "virtualStatus" in input;
|
|
1191
1246
|
let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
|
|
1192
1247
|
let hasBoardingUtxos = false;
|
|
1193
1248
|
let connectorIndex = 0;
|
|
1194
1249
|
const connectorsLeaves = connectorsGraph?.leaves() || [];
|
|
1195
1250
|
for (const input of inputs) {
|
|
1196
|
-
// check if the input is an offchain "virtual" coin
|
|
1197
|
-
const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
|
|
1198
1251
|
// boarding input, we need to sign the settlement tx
|
|
1199
|
-
if (!
|
|
1252
|
+
if (!isVtxo(input)) {
|
|
1200
1253
|
for (let i = 0; i < settlementPsbt.inputsLength; i++) {
|
|
1201
1254
|
const settlementInput = settlementPsbt.getInput(i);
|
|
1202
1255
|
if (!settlementInput.txid ||
|
|
@@ -1220,7 +1273,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1220
1273
|
}
|
|
1221
1274
|
continue;
|
|
1222
1275
|
}
|
|
1223
|
-
if (isRecoverable(
|
|
1276
|
+
if (isRecoverable(input) || isSubdust(input, this.dustAmount)) {
|
|
1224
1277
|
// recoverable or subdust coin, we don't need to create a forfeit tx
|
|
1225
1278
|
continue;
|
|
1226
1279
|
}
|
|
@@ -1247,7 +1300,7 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1247
1300
|
txid: input.txid,
|
|
1248
1301
|
index: input.vout,
|
|
1249
1302
|
witnessUtxo: {
|
|
1250
|
-
amount: BigInt(
|
|
1303
|
+
amount: BigInt(input.value),
|
|
1251
1304
|
script: VtxoScript.decode(input.tapTree).pkScript,
|
|
1252
1305
|
},
|
|
1253
1306
|
sighashType: SigHash.DEFAULT,
|
|
@@ -1676,9 +1729,17 @@ export class Wallet extends ReadonlyWallet {
|
|
|
1676
1729
|
outputs.push(Extension.create([assetPacket]).txOut());
|
|
1677
1730
|
}
|
|
1678
1731
|
const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1732
|
+
// Optimistically hide selected coins from concurrent getVtxos() while
|
|
1733
|
+
// the offchain tx is in flight.
|
|
1734
|
+
this._addPendingSpends(selectedCoins);
|
|
1735
|
+
try {
|
|
1736
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
|
|
1737
|
+
await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
|
|
1738
|
+
return arkTxid;
|
|
1739
|
+
}
|
|
1740
|
+
finally {
|
|
1741
|
+
this._removePendingSpends(selectedCoins);
|
|
1742
|
+
}
|
|
1682
1743
|
}
|
|
1683
1744
|
/**
|
|
1684
1745
|
* Build an offchain transaction from the given inputs and outputs,
|
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
import { getActiveServiceWorker, setupServiceWorkerOnce, } from './browser/service-worker-manager.js';
|
|
3
3
|
import { RestArkProvider } from '../providers/ark.js';
|
|
4
4
|
import { RestDelegatorProvider } from '../providers/delegator.js';
|
|
5
|
-
import {
|
|
5
|
+
import { hydrateIdentity, isSigningSerialized, normalizeSerializedIdentity, } from '../identity/index.js';
|
|
6
6
|
import { ReadonlyWallet, Wallet } from '../wallet/wallet.js';
|
|
7
|
-
import { hex } from "@scure/base";
|
|
8
7
|
import { MessageBusNotInitializedError, ServiceWorkerTimeoutError, } from './errors.js';
|
|
8
|
+
/**
|
|
9
|
+
* Grace period after a handler times out during which late handler
|
|
10
|
+
* completion is still delivered to the client. Once this expires,
|
|
11
|
+
* the bus sends an "Operation abandoned" error so the message id
|
|
12
|
+
* never goes silent indefinitely.
|
|
13
|
+
*/
|
|
14
|
+
const LATE_DELIVERY_GRACE_MS = 5 * 60000;
|
|
9
15
|
export class MessageBus {
|
|
10
16
|
/** Create the service-worker message bus with repositories and handler configuration. */
|
|
11
|
-
constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
|
|
17
|
+
constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, messageTimeoutOverrides = {}, debug = false, buildServices, }) {
|
|
12
18
|
this.walletRepository = walletRepository;
|
|
13
19
|
this.contractRepository = contractRepository;
|
|
20
|
+
this.lateDeliveries = new Set();
|
|
14
21
|
this.running = false;
|
|
15
22
|
this.tickTimeout = null;
|
|
16
23
|
this.tickInProgress = false;
|
|
@@ -20,6 +27,8 @@ export class MessageBus {
|
|
|
20
27
|
this.handlers = new Map(messageHandlers.map((u) => [u.messageTag, u]));
|
|
21
28
|
this.tickIntervalMs = tickIntervalMs;
|
|
22
29
|
this.messageTimeoutMs = messageTimeoutMs;
|
|
30
|
+
this.constructorTimeoutOverrides = { ...messageTimeoutOverrides };
|
|
31
|
+
this.messageTimeoutOverrides = { ...this.constructorTimeoutOverrides };
|
|
23
32
|
this.debug = debug;
|
|
24
33
|
this.buildServicesFn = buildServices ?? this.buildServices.bind(this);
|
|
25
34
|
}
|
|
@@ -55,6 +64,11 @@ export class MessageBus {
|
|
|
55
64
|
self.clearTimeout(this.tickTimeout);
|
|
56
65
|
this.tickTimeout = null;
|
|
57
66
|
}
|
|
67
|
+
for (const record of this.lateDeliveries) {
|
|
68
|
+
record.settled = true;
|
|
69
|
+
self.clearTimeout(record.deadline);
|
|
70
|
+
}
|
|
71
|
+
this.lateDeliveries.clear();
|
|
58
72
|
self.removeEventListener("message", this.boundOnMessage);
|
|
59
73
|
await Promise.all(Array.from(this.handlers.values()).map((updater) => updater.stop()));
|
|
60
74
|
}
|
|
@@ -81,7 +95,8 @@ export class MessageBus {
|
|
|
81
95
|
const now = Date.now();
|
|
82
96
|
for (const updater of this.handlers.values()) {
|
|
83
97
|
try {
|
|
84
|
-
const
|
|
98
|
+
const tickLabel = `${updater.messageTag}:tick`;
|
|
99
|
+
const response = await this.withTimeout(updater.tick(now), this.resolveTimeoutMs(tickLabel, updater.messageTag), tickLabel);
|
|
85
100
|
if (this.debug)
|
|
86
101
|
console.log(`[${updater.messageTag}] outgoing tick response:`, response);
|
|
87
102
|
if (response && response.length > 0) {
|
|
@@ -124,6 +139,12 @@ export class MessageBus {
|
|
|
124
139
|
this.initialized = false;
|
|
125
140
|
await Promise.all(Array.from(this.handlers.values()).map((h) => h.stop().catch(() => { })));
|
|
126
141
|
}
|
|
142
|
+
// Recompute the active timeout map from scratch so a prior init's
|
|
143
|
+
// keys cannot linger after re-init with a smaller map.
|
|
144
|
+
this.messageTimeoutOverrides = {
|
|
145
|
+
...this.constructorTimeoutOverrides,
|
|
146
|
+
...(config.messageTimeouts ?? {}),
|
|
147
|
+
};
|
|
127
148
|
const services = await this.buildServicesFn(config);
|
|
128
149
|
// Start all handlers
|
|
129
150
|
for (const updater of this.handlers.values()) {
|
|
@@ -146,8 +167,9 @@ export class MessageBus {
|
|
|
146
167
|
const delegatorProvider = config.delegatorUrl
|
|
147
168
|
? new RestDelegatorProvider(config.delegatorUrl)
|
|
148
169
|
: undefined;
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
const serialized = normalizeSerializedIdentity(config.wallet);
|
|
171
|
+
if (isSigningSerialized(serialized)) {
|
|
172
|
+
const identity = hydrateIdentity(serialized);
|
|
151
173
|
const wallet = await Wallet.create({
|
|
152
174
|
identity,
|
|
153
175
|
arkServerUrl: config.arkServer.url,
|
|
@@ -161,23 +183,18 @@ export class MessageBus {
|
|
|
161
183
|
});
|
|
162
184
|
return { wallet, arkProvider, readonlyWallet: wallet };
|
|
163
185
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return { readonlyWallet, arkProvider };
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
throw new Error("Missing privateKey or publicKey in configuration object");
|
|
180
|
-
}
|
|
186
|
+
const identity = hydrateIdentity(serialized);
|
|
187
|
+
const readonlyWallet = await ReadonlyWallet.create({
|
|
188
|
+
identity,
|
|
189
|
+
arkServerUrl: config.arkServer.url,
|
|
190
|
+
arkServerPublicKey: config.arkServer.publicKey,
|
|
191
|
+
indexerUrl: config.indexerUrl,
|
|
192
|
+
esploraUrl: config.esploraUrl,
|
|
193
|
+
storage,
|
|
194
|
+
delegatorProvider,
|
|
195
|
+
watcherConfig: config.watcherConfig,
|
|
196
|
+
});
|
|
197
|
+
return { readonlyWallet, arkProvider };
|
|
181
198
|
}
|
|
182
199
|
onMessage(event) {
|
|
183
200
|
// Keep the service worker alive while async work is pending.
|
|
@@ -192,7 +209,7 @@ export class MessageBus {
|
|
|
192
209
|
async processMessage(event) {
|
|
193
210
|
const { id, tag, broadcast } = event.data;
|
|
194
211
|
if (tag === "PING") {
|
|
195
|
-
event.source
|
|
212
|
+
this.deliverResponse(event.source, { id, tag: "PONG" }, { id, tag: "PONG" });
|
|
196
213
|
return;
|
|
197
214
|
}
|
|
198
215
|
if (tag === "INITIALIZE_MESSAGE_BUS") {
|
|
@@ -203,7 +220,7 @@ export class MessageBus {
|
|
|
203
220
|
// performs network calls (buildServices) and handler startup
|
|
204
221
|
// that may legitimately exceed the message timeout.
|
|
205
222
|
await this.waitForInit(event.data.config);
|
|
206
|
-
event.source
|
|
223
|
+
this.deliverResponse(event.source, { id, tag }, { id, tag });
|
|
207
224
|
if (this.debug) {
|
|
208
225
|
console.log("MessageBus initialized");
|
|
209
226
|
}
|
|
@@ -216,45 +233,60 @@ export class MessageBus {
|
|
|
216
233
|
// hanging forever. This happens when the browser kills and restarts
|
|
217
234
|
// the service worker — the new instance has initialized=false and
|
|
218
235
|
// messages arrive before INITIALIZE_MESSAGE_BUS is re-sent.
|
|
219
|
-
|
|
236
|
+
const fallbackTag = tag ?? "unknown";
|
|
237
|
+
this.deliverResponse(event.source, {
|
|
220
238
|
id,
|
|
221
|
-
tag:
|
|
239
|
+
tag: fallbackTag,
|
|
222
240
|
error: new MessageBusNotInitializedError(),
|
|
223
|
-
});
|
|
241
|
+
}, { id, tag: fallbackTag });
|
|
224
242
|
return;
|
|
225
243
|
}
|
|
226
244
|
if (!id || !tag) {
|
|
227
245
|
if (this.debug)
|
|
228
246
|
console.error("Invalid message received, missing required fields:", event.data);
|
|
229
|
-
|
|
247
|
+
const fallbackTag = tag ?? "unknown";
|
|
248
|
+
this.deliverResponse(event.source, {
|
|
230
249
|
id,
|
|
231
|
-
tag:
|
|
250
|
+
tag: fallbackTag,
|
|
232
251
|
error: new TypeError("Invalid message received, missing required fields"),
|
|
233
|
-
});
|
|
252
|
+
}, { id, tag: fallbackTag });
|
|
234
253
|
return;
|
|
235
254
|
}
|
|
255
|
+
const messageType = this.extractMessageType(event.data);
|
|
236
256
|
if (broadcast) {
|
|
237
257
|
const updaters = Array.from(this.handlers.values());
|
|
238
|
-
const
|
|
258
|
+
const entries = updaters.map((updater) => {
|
|
259
|
+
const label = this.labelFor(messageType, updater.messageTag);
|
|
260
|
+
const timeoutMs = this.resolveTimeoutMs(messageType, updater.messageTag);
|
|
261
|
+
const handlerPromise = updater.handleMessage(event.data);
|
|
262
|
+
const raced = updater.isLongRunning?.(event.data)
|
|
263
|
+
? handlerPromise
|
|
264
|
+
: this.withTimeout(handlerPromise, timeoutMs, label);
|
|
265
|
+
return { updater, handlerPromise, raced };
|
|
266
|
+
});
|
|
267
|
+
const results = await Promise.allSettled(entries.map((e) => e.raced));
|
|
239
268
|
results.forEach((result, index) => {
|
|
240
|
-
const updater =
|
|
269
|
+
const { updater, handlerPromise } = entries[index];
|
|
270
|
+
const handlerTag = updater.messageTag;
|
|
271
|
+
const context = { id, tag: handlerTag, messageType };
|
|
241
272
|
if (result.status === "fulfilled") {
|
|
242
273
|
const response = result.value;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
274
|
+
// Always deliver a response so the caller's message id
|
|
275
|
+
// never goes silent. Handlers returning null/undefined
|
|
276
|
+
// get an explicit ack envelope.
|
|
277
|
+
this.deliverResponse(event.source, response ?? { id, tag: handlerTag }, context);
|
|
246
278
|
}
|
|
247
279
|
else {
|
|
248
280
|
if (this.debug)
|
|
249
|
-
console.error(`[${
|
|
250
|
-
const error = result.reason
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
281
|
+
console.error(`[${handlerTag}] handleMessage failed`, result.reason);
|
|
282
|
+
const error = toError(result.reason);
|
|
283
|
+
this.deliverResponse(event.source, { id, tag: handlerTag, error }, context);
|
|
284
|
+
// If the error was a timeout, keep watching the
|
|
285
|
+
// underlying handler and surface its eventual result
|
|
286
|
+
// under the same id.
|
|
287
|
+
if (result.reason instanceof ServiceWorkerTimeoutError) {
|
|
288
|
+
this.attachLateDelivery(handlerPromise, event.source, id, handlerTag, messageType);
|
|
289
|
+
}
|
|
258
290
|
}
|
|
259
291
|
});
|
|
260
292
|
return;
|
|
@@ -263,35 +295,53 @@ export class MessageBus {
|
|
|
263
295
|
if (!updater) {
|
|
264
296
|
if (this.debug)
|
|
265
297
|
console.warn(`[${tag}] unknown message tag, ignoring message`);
|
|
298
|
+
this.deliverResponse(event.source, {
|
|
299
|
+
id,
|
|
300
|
+
tag,
|
|
301
|
+
error: new Error(`Unknown handler tag: ${tag}`),
|
|
302
|
+
}, { id, tag, messageType });
|
|
266
303
|
return;
|
|
267
304
|
}
|
|
305
|
+
const label = this.labelFor(messageType, tag);
|
|
306
|
+
const timeoutMs = this.resolveTimeoutMs(messageType, tag);
|
|
307
|
+
const handlerPromise = updater.handleMessage(event.data);
|
|
308
|
+
const context = { id, tag, messageType };
|
|
268
309
|
try {
|
|
269
|
-
const response =
|
|
310
|
+
const response = updater.isLongRunning?.(event.data)
|
|
311
|
+
? await handlerPromise
|
|
312
|
+
: await this.withTimeout(handlerPromise, timeoutMs, label);
|
|
270
313
|
if (this.debug)
|
|
271
314
|
console.log(`[${tag}] outgoing response:`, response);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
315
|
+
// Always deliver a response so the caller's message id never
|
|
316
|
+
// goes silent. A handler returning null/undefined yields an
|
|
317
|
+
// explicit ack envelope.
|
|
318
|
+
this.deliverResponse(event.source, response ?? { id, tag }, context);
|
|
275
319
|
}
|
|
276
320
|
catch (err) {
|
|
277
321
|
if (this.debug)
|
|
278
322
|
console.error(`[${tag}] handleMessage failed`, err);
|
|
279
|
-
const error =
|
|
280
|
-
event.source
|
|
323
|
+
const error = toError(err);
|
|
324
|
+
this.deliverResponse(event.source, { id, tag, error }, context);
|
|
325
|
+
// When we abandoned the handler via timeout, keep watching it
|
|
326
|
+
// so the client's message id eventually gets a final response.
|
|
327
|
+
if (err instanceof ServiceWorkerTimeoutError) {
|
|
328
|
+
this.attachLateDelivery(handlerPromise, event.source, id, tag, messageType);
|
|
329
|
+
}
|
|
281
330
|
}
|
|
282
331
|
}
|
|
283
332
|
/**
|
|
284
333
|
* Race `promise` against a timeout. Note: this does NOT cancel the
|
|
285
|
-
* underlying work — the original promise keeps running.
|
|
286
|
-
*
|
|
334
|
+
* underlying work — the original promise keeps running. Call
|
|
335
|
+
* `attachLateDelivery` after catching the timeout to surface the
|
|
336
|
+
* eventual result so the message id does not go silent.
|
|
287
337
|
*/
|
|
288
|
-
withTimeout(promise, label) {
|
|
289
|
-
if (
|
|
338
|
+
withTimeout(promise, timeoutMs, label) {
|
|
339
|
+
if (timeoutMs <= 0)
|
|
290
340
|
return promise;
|
|
291
341
|
return new Promise((resolve, reject) => {
|
|
292
342
|
const timer = self.setTimeout(() => {
|
|
293
|
-
reject(new ServiceWorkerTimeoutError(`Message handler timed out after ${
|
|
294
|
-
},
|
|
343
|
+
reject(new ServiceWorkerTimeoutError(`Message handler timed out after ${timeoutMs}ms (${label})`));
|
|
344
|
+
}, timeoutMs);
|
|
295
345
|
promise.then((val) => {
|
|
296
346
|
self.clearTimeout(timer);
|
|
297
347
|
resolve(val);
|
|
@@ -301,6 +351,97 @@ export class MessageBus {
|
|
|
301
351
|
});
|
|
302
352
|
});
|
|
303
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Extract the declared `type` from a request envelope (e.g. "SETTLE").
|
|
356
|
+
* Not every envelope carries a type (PING/INIT are special cased
|
|
357
|
+
* earlier), so this returns undefined for envelopes that lack one.
|
|
358
|
+
*/
|
|
359
|
+
extractMessageType(data) {
|
|
360
|
+
const maybeType = data.type;
|
|
361
|
+
return typeof maybeType === "string" ? maybeType : undefined;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Resolve the timeout for an operation. Message-type overrides take
|
|
365
|
+
* precedence over handler-tag overrides, with the bus-wide default
|
|
366
|
+
* (`messageTimeoutMs`) as the final fallback.
|
|
367
|
+
*/
|
|
368
|
+
resolveTimeoutMs(messageType, handlerTag) {
|
|
369
|
+
if (messageType &&
|
|
370
|
+
Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, messageType)) {
|
|
371
|
+
return this.messageTimeoutOverrides[messageType];
|
|
372
|
+
}
|
|
373
|
+
if (Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, handlerTag)) {
|
|
374
|
+
return this.messageTimeoutOverrides[handlerTag];
|
|
375
|
+
}
|
|
376
|
+
return this.messageTimeoutMs;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Build a human-readable label for timeout errors. Format:
|
|
380
|
+
* `"<MESSAGE_TYPE> via <HANDLER_TAG>"` when both are known, else the
|
|
381
|
+
* handler tag alone. Used so timeout errors name the operation the
|
|
382
|
+
* client actually triggered (e.g. SETTLE) rather than just the
|
|
383
|
+
* handler that received it (e.g. WALLET_UPDATER).
|
|
384
|
+
*/
|
|
385
|
+
labelFor(messageType, handlerTag) {
|
|
386
|
+
return messageType ? `${messageType} via ${handlerTag}` : handlerTag;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Post a response to the originating client. When `source` is null
|
|
390
|
+
* (client tab closed, detached frame, etc.) the response cannot be
|
|
391
|
+
* delivered; we log the drop in debug mode so it is not invisible.
|
|
392
|
+
*/
|
|
393
|
+
deliverResponse(source, response, context) {
|
|
394
|
+
if (!source) {
|
|
395
|
+
if (this.debug)
|
|
396
|
+
console.warn(`[${context.tag}] cannot deliver response: event.source is null`, {
|
|
397
|
+
id: context.id,
|
|
398
|
+
messageType: context.messageType,
|
|
399
|
+
});
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
source.postMessage(response);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* After a handler times out the client has already received a timeout
|
|
406
|
+
* error, but the handler keeps running. Attach a follow-up so the
|
|
407
|
+
* handler's eventual result (or error) is delivered under the same
|
|
408
|
+
* message id, or — if the handler never completes within
|
|
409
|
+
* {@link LATE_DELIVERY_GRACE_MS} — an "Operation abandoned" error is
|
|
410
|
+
* sent so the client's listener (if still attached) does not hang.
|
|
411
|
+
*/
|
|
412
|
+
attachLateDelivery(handlerPromise, source, id, tag, messageType) {
|
|
413
|
+
const context = { id, tag, messageType };
|
|
414
|
+
const record = {
|
|
415
|
+
settled: false,
|
|
416
|
+
deadline: self.setTimeout(() => {
|
|
417
|
+
if (record.settled)
|
|
418
|
+
return;
|
|
419
|
+
record.settled = true;
|
|
420
|
+
this.lateDeliveries.delete(record);
|
|
421
|
+
this.deliverResponse(source, {
|
|
422
|
+
id,
|
|
423
|
+
tag,
|
|
424
|
+
error: new Error(`Operation abandoned: handler did not complete within ${LATE_DELIVERY_GRACE_MS}ms after timeout (${this.labelFor(messageType, tag)})`),
|
|
425
|
+
}, context);
|
|
426
|
+
}, LATE_DELIVERY_GRACE_MS),
|
|
427
|
+
};
|
|
428
|
+
this.lateDeliveries.add(record);
|
|
429
|
+
handlerPromise.then((response) => {
|
|
430
|
+
if (record.settled)
|
|
431
|
+
return;
|
|
432
|
+
record.settled = true;
|
|
433
|
+
self.clearTimeout(record.deadline);
|
|
434
|
+
this.lateDeliveries.delete(record);
|
|
435
|
+
this.deliverResponse(source, response ?? { id, tag }, context);
|
|
436
|
+
}, (err) => {
|
|
437
|
+
if (record.settled)
|
|
438
|
+
return;
|
|
439
|
+
record.settled = true;
|
|
440
|
+
self.clearTimeout(record.deadline);
|
|
441
|
+
this.lateDeliveries.delete(record);
|
|
442
|
+
this.deliverResponse(source, { id, tag, error: toError(err) }, context);
|
|
443
|
+
});
|
|
444
|
+
}
|
|
304
445
|
/**
|
|
305
446
|
* Returns the registered SW for the path.
|
|
306
447
|
* It uses the functions in `service-worker-manager.ts` module.
|
|
@@ -323,3 +464,6 @@ export class MessageBus {
|
|
|
323
464
|
return getActiveServiceWorker(path);
|
|
324
465
|
}
|
|
325
466
|
}
|
|
467
|
+
function toError(value) {
|
|
468
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
469
|
+
}
|
|
@@ -10,7 +10,7 @@ export interface DefaultContractParams {
|
|
|
10
10
|
csvTimelock: RelativeTimelock;
|
|
11
11
|
}
|
|
12
12
|
/**
|
|
13
|
-
* Handler for default wallet
|
|
13
|
+
* Handler for default wallet VTXOs.
|
|
14
14
|
*
|
|
15
15
|
* Default contracts use the standard forfeit + exit tapscript:
|
|
16
16
|
* - forfeit: (Alice + Server) multisig for collaborative spending
|
|
@@ -9,7 +9,7 @@ export declare function timelockToSequence(timelock: RelativeTimelock): number;
|
|
|
9
9
|
*/
|
|
10
10
|
export declare function sequenceToTimelock(sequence: number): RelativeTimelock;
|
|
11
11
|
/**
|
|
12
|
-
* Resolve wallet's role from explicit role or by matching pubkey.
|
|
12
|
+
* Resolve wallet's role from explicit role or by matching descriptor/pubkey.
|
|
13
13
|
*/
|
|
14
14
|
export declare function resolveRole(contract: Contract, context: PathContext): "sender" | "receiver" | undefined;
|
|
15
15
|
/**
|
|
@@ -96,13 +96,21 @@ export interface PathContext {
|
|
|
96
96
|
/** Current block height, when known. */
|
|
97
97
|
blockHeight?: number;
|
|
98
98
|
/**
|
|
99
|
-
* Wallet
|
|
100
|
-
*
|
|
99
|
+
* Wallet's descriptor for signing.
|
|
100
|
+
* Format: tr(pubkey) for static keys, tr([fingerprint/path']xpub/0/{index}) for HD.
|
|
101
|
+
* Used by handlers to determine wallet's role in multi-party contracts.
|
|
102
|
+
*/
|
|
103
|
+
walletDescriptor?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Wallet's public key (x-only, 32 bytes hex).
|
|
106
|
+
* @deprecated Use walletDescriptor instead.
|
|
101
107
|
*/
|
|
102
108
|
walletPubKey?: string;
|
|
103
109
|
/**
|
|
104
110
|
* Explicit role override for multi-party contracts such as VHTLC.
|
|
105
|
-
* If not provided, the handler may derive the role
|
|
111
|
+
* If not provided, the handler may derive the role by matching
|
|
112
|
+
* {@link walletDescriptor} (preferred) — or {@link walletPubKey} as a
|
|
113
|
+
* fallback — against the contract's sender/receiver params.
|
|
106
114
|
*/
|
|
107
115
|
role?: string;
|
|
108
116
|
/** The specific virtual output being evaluated. */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a string is a descriptor of the shape `tr(...)`.
|
|
3
|
+
*
|
|
4
|
+
* This is a shape check only — it does not validate the inner key material.
|
|
5
|
+
* Use {@link expand} (via {@link extractPubKey} / {@link parseHDDescriptor})
|
|
6
|
+
* for full parsing. The guard rejects empty bodies and missing/trailing
|
|
7
|
+
* parentheses so callers can safely branch on descriptor vs. raw pubkey.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isDescriptor(value: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a value to descriptor format.
|
|
12
|
+
* If already a descriptor, return as-is. If hex pubkey, wrap as tr(pubkey).
|
|
13
|
+
* Throws when the value is empty or not a string so we never produce
|
|
14
|
+
* malformed descriptors like `tr()` that downstream parsers would reject.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeToDescriptor(value: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Extract the public key from a simple descriptor.
|
|
19
|
+
* For simple descriptors (tr(pubkey)), extracts the pubkey using the library.
|
|
20
|
+
* For HD descriptors, throws — use DescriptorProvider to derive the key.
|
|
21
|
+
*/
|
|
22
|
+
export declare function extractPubKey(descriptor: string): string;
|
|
23
|
+
/** Parsed HD descriptor components. */
|
|
24
|
+
export interface ParsedHDDescriptor {
|
|
25
|
+
fingerprint: string;
|
|
26
|
+
basePath: string;
|
|
27
|
+
xpub: string;
|
|
28
|
+
derivationPath: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse an HD descriptor into its components.
|
|
32
|
+
* HD descriptors have the format: tr([fingerprint/path']xpub/derivation)
|
|
33
|
+
* Returns null if the descriptor is not in HD format.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseHDDescriptor(descriptor: string): ParsedHDDescriptor | null;
|