@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
|
@@ -68,6 +68,12 @@ class ReadonlyWallet {
|
|
|
68
68
|
this.walletRepository = walletRepository;
|
|
69
69
|
this.contractRepository = contractRepository;
|
|
70
70
|
this.delegatorProvider = delegatorProvider;
|
|
71
|
+
// Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
|
|
72
|
+
// from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
|
|
73
|
+
// another send/settle racing the _txLock) can't reselect coins that are
|
|
74
|
+
// already on their way out. The set is in-memory only: a process crash
|
|
75
|
+
// clears it, and a stale entry only hides a VTXO (never spends one).
|
|
76
|
+
this._pendingSpendOutpoints = new Set();
|
|
71
77
|
// Guard: detect identity/server network mismatch for descriptor-based identities.
|
|
72
78
|
// This duplicates the check in setupWalletConfig() so that subclasses
|
|
73
79
|
// bypassing the factory still get the safety net.
|
|
@@ -308,6 +314,9 @@ class ReadonlyWallet {
|
|
|
308
314
|
return vtxos
|
|
309
315
|
.flatMap((_) => _.vtxos)
|
|
310
316
|
.filter((vtxo) => {
|
|
317
|
+
if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
311
320
|
if ((0, _1.isSpendable)(vtxo)) {
|
|
312
321
|
if (!f.withRecoverable &&
|
|
313
322
|
((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
|
|
@@ -760,6 +769,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
|
|
|
760
769
|
* ```
|
|
761
770
|
*/
|
|
762
771
|
class Wallet extends ReadonlyWallet {
|
|
772
|
+
_addPendingSpends(inputs) {
|
|
773
|
+
for (const input of inputs) {
|
|
774
|
+
if ("virtualStatus" in input) {
|
|
775
|
+
this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
_removePendingSpends(inputs) {
|
|
780
|
+
for (const input of inputs) {
|
|
781
|
+
if ("virtualStatus" in input) {
|
|
782
|
+
this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
763
786
|
_withTxLock(fn) {
|
|
764
787
|
let release;
|
|
765
788
|
const lock = new Promise((r) => (release = r));
|
|
@@ -975,9 +998,15 @@ class Wallet extends ReadonlyWallet {
|
|
|
975
998
|
amount: BigInt(selected.changeAmount),
|
|
976
999
|
});
|
|
977
1000
|
}
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1001
|
+
this._addPendingSpends(selected.inputs);
|
|
1002
|
+
try {
|
|
1003
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
|
|
1004
|
+
await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
|
|
1005
|
+
return arkTxid;
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
this._removePendingSpends(selected.inputs);
|
|
1009
|
+
}
|
|
981
1010
|
});
|
|
982
1011
|
}
|
|
983
1012
|
return this.send({
|
|
@@ -1023,7 +1052,8 @@ class Wallet extends ReadonlyWallet {
|
|
|
1023
1052
|
const tip = await this.onchainProvider.getChainTip();
|
|
1024
1053
|
chainTipHeight = tip.height;
|
|
1025
1054
|
}
|
|
1026
|
-
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) =>
|
|
1055
|
+
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
|
|
1056
|
+
!(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
|
|
1027
1057
|
const filteredBoardingUtxos = [];
|
|
1028
1058
|
for (const utxo of boardingUtxos) {
|
|
1029
1059
|
const inputFee = estimator.evalOnchainInput({
|
|
@@ -1047,10 +1077,10 @@ class Wallet extends ReadonlyWallet {
|
|
|
1047
1077
|
weight: 0,
|
|
1048
1078
|
birth: vtxo.createdAt,
|
|
1049
1079
|
expiry: vtxo.virtualStatus.batchExpiry
|
|
1050
|
-
? new Date(vtxo.virtualStatus.batchExpiry
|
|
1051
|
-
:
|
|
1080
|
+
? new Date(vtxo.virtualStatus.batchExpiry)
|
|
1081
|
+
: undefined,
|
|
1052
1082
|
});
|
|
1053
|
-
if (inputFee.
|
|
1083
|
+
if (inputFee.satoshis >= vtxo.value) {
|
|
1054
1084
|
// skip if fees are greater than the virtual output value
|
|
1055
1085
|
continue;
|
|
1056
1086
|
}
|
|
@@ -1158,11 +1188,29 @@ class Wallet extends ReadonlyWallet {
|
|
|
1158
1188
|
...params.inputs.map((input) => `${input.txid}:${input.vout}`),
|
|
1159
1189
|
];
|
|
1160
1190
|
const abortController = new AbortController();
|
|
1191
|
+
let stream;
|
|
1192
|
+
// Optimistically hide these inputs from concurrent getVtxos() callers
|
|
1193
|
+
// while the settlement is in flight. Set before safeRegisterIntent so
|
|
1194
|
+
// there's no window between intent registration and coin-visibility.
|
|
1195
|
+
this._addPendingSpends(params.inputs);
|
|
1161
1196
|
try {
|
|
1162
|
-
|
|
1197
|
+
stream = this.arkProvider.getEventStream(abortController.signal, topics);
|
|
1198
|
+
// Prime the iterator so the provider opens the SSE subscription
|
|
1199
|
+
// before safeRegisterIntent can trigger server-side batch events.
|
|
1200
|
+
const firstNext = stream.next();
|
|
1201
|
+
// If settle exits before Batch.join consumes the primed result,
|
|
1202
|
+
// keep the orphaned promise from surfacing as an unhandled rejection.
|
|
1203
|
+
void firstNext.catch(() => { });
|
|
1204
|
+
const primedStream = (async function* () {
|
|
1205
|
+
const first = await firstNext;
|
|
1206
|
+
if (!first.done) {
|
|
1207
|
+
yield first.value;
|
|
1208
|
+
}
|
|
1209
|
+
yield* stream;
|
|
1210
|
+
})();
|
|
1163
1211
|
const intentId = await this.safeRegisterIntent(intent, params.inputs);
|
|
1164
1212
|
const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
|
|
1165
|
-
const commitmentTxid = await batch_1.Batch.join(
|
|
1213
|
+
const commitmentTxid = await batch_1.Batch.join(primedStream, handler, {
|
|
1166
1214
|
abortController,
|
|
1167
1215
|
skipVtxoTreeSigning: !hasOffchainOutputs,
|
|
1168
1216
|
eventCallback: eventCallback
|
|
@@ -1186,23 +1234,28 @@ class Wallet extends ReadonlyWallet {
|
|
|
1186
1234
|
throw error;
|
|
1187
1235
|
}
|
|
1188
1236
|
finally {
|
|
1189
|
-
//
|
|
1237
|
+
// Clear state first so a synchronous handler firing from abort()
|
|
1238
|
+
// never observes a stale pending-spend set.
|
|
1239
|
+
this._removePendingSpends(params.inputs);
|
|
1240
|
+
// close the stream — abort() fires the in-body handler if the
|
|
1241
|
+
// generator has started iterating; return() also releases the
|
|
1242
|
+
// eager resource if the body is still suspended or never ran
|
|
1243
|
+
// (e.g. safeRegisterIntent threw before Batch.join was called).
|
|
1190
1244
|
abortController.abort();
|
|
1245
|
+
await stream?.return?.().catch(() => { });
|
|
1191
1246
|
}
|
|
1192
1247
|
}
|
|
1193
1248
|
async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
|
|
1194
1249
|
// the signed forfeits transactions to submit
|
|
1195
1250
|
const signedForfeits = [];
|
|
1196
|
-
const
|
|
1251
|
+
const isVtxo = (input) => "virtualStatus" in input;
|
|
1197
1252
|
let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.commitmentTx));
|
|
1198
1253
|
let hasBoardingUtxos = false;
|
|
1199
1254
|
let connectorIndex = 0;
|
|
1200
1255
|
const connectorsLeaves = connectorsGraph?.leaves() || [];
|
|
1201
1256
|
for (const input of inputs) {
|
|
1202
|
-
// check if the input is an offchain "virtual" coin
|
|
1203
|
-
const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
|
|
1204
1257
|
// boarding input, we need to sign the settlement tx
|
|
1205
|
-
if (!
|
|
1258
|
+
if (!isVtxo(input)) {
|
|
1206
1259
|
for (let i = 0; i < settlementPsbt.inputsLength; i++) {
|
|
1207
1260
|
const settlementInput = settlementPsbt.getInput(i);
|
|
1208
1261
|
if (!settlementInput.txid ||
|
|
@@ -1226,7 +1279,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1226
1279
|
}
|
|
1227
1280
|
continue;
|
|
1228
1281
|
}
|
|
1229
|
-
if ((0, _1.isRecoverable)(
|
|
1282
|
+
if ((0, _1.isRecoverable)(input) || (0, _1.isSubdust)(input, this.dustAmount)) {
|
|
1230
1283
|
// recoverable or subdust coin, we don't need to create a forfeit tx
|
|
1231
1284
|
continue;
|
|
1232
1285
|
}
|
|
@@ -1253,7 +1306,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1253
1306
|
txid: input.txid,
|
|
1254
1307
|
index: input.vout,
|
|
1255
1308
|
witnessUtxo: {
|
|
1256
|
-
amount: BigInt(
|
|
1309
|
+
amount: BigInt(input.value),
|
|
1257
1310
|
script: base_2.VtxoScript.decode(input.tapTree).pkScript,
|
|
1258
1311
|
},
|
|
1259
1312
|
sighashType: btc_signer_1.SigHash.DEFAULT,
|
|
@@ -1682,9 +1735,17 @@ class Wallet extends ReadonlyWallet {
|
|
|
1682
1735
|
outputs.push(extension_1.Extension.create([assetPacket]).txOut());
|
|
1683
1736
|
}
|
|
1684
1737
|
const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1738
|
+
// Optimistically hide selected coins from concurrent getVtxos() while
|
|
1739
|
+
// the offchain tx is in flight.
|
|
1740
|
+
this._addPendingSpends(selectedCoins);
|
|
1741
|
+
try {
|
|
1742
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
|
|
1743
|
+
await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
|
|
1744
|
+
return arkTxid;
|
|
1745
|
+
}
|
|
1746
|
+
finally {
|
|
1747
|
+
this._removePendingSpends(selectedCoins);
|
|
1748
|
+
}
|
|
1688
1749
|
}
|
|
1689
1750
|
/**
|
|
1690
1751
|
* Build an offchain transaction from the given inputs and outputs,
|
|
@@ -7,13 +7,20 @@ const ark_1 = require("../providers/ark");
|
|
|
7
7
|
const delegator_1 = require("../providers/delegator");
|
|
8
8
|
const identity_1 = require("../identity");
|
|
9
9
|
const wallet_1 = require("../wallet/wallet");
|
|
10
|
-
const base_1 = require("@scure/base");
|
|
11
10
|
const errors_1 = require("./errors");
|
|
11
|
+
/**
|
|
12
|
+
* Grace period after a handler times out during which late handler
|
|
13
|
+
* completion is still delivered to the client. Once this expires,
|
|
14
|
+
* the bus sends an "Operation abandoned" error so the message id
|
|
15
|
+
* never goes silent indefinitely.
|
|
16
|
+
*/
|
|
17
|
+
const LATE_DELIVERY_GRACE_MS = 5 * 60000;
|
|
12
18
|
class MessageBus {
|
|
13
19
|
/** Create the service-worker message bus with repositories and handler configuration. */
|
|
14
|
-
constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, debug = false, buildServices, }) {
|
|
20
|
+
constructor(walletRepository, contractRepository, { messageHandlers, tickIntervalMs = 10000, messageTimeoutMs = 30000, messageTimeoutOverrides = {}, debug = false, buildServices, }) {
|
|
15
21
|
this.walletRepository = walletRepository;
|
|
16
22
|
this.contractRepository = contractRepository;
|
|
23
|
+
this.lateDeliveries = new Set();
|
|
17
24
|
this.running = false;
|
|
18
25
|
this.tickTimeout = null;
|
|
19
26
|
this.tickInProgress = false;
|
|
@@ -23,6 +30,8 @@ class MessageBus {
|
|
|
23
30
|
this.handlers = new Map(messageHandlers.map((u) => [u.messageTag, u]));
|
|
24
31
|
this.tickIntervalMs = tickIntervalMs;
|
|
25
32
|
this.messageTimeoutMs = messageTimeoutMs;
|
|
33
|
+
this.constructorTimeoutOverrides = { ...messageTimeoutOverrides };
|
|
34
|
+
this.messageTimeoutOverrides = { ...this.constructorTimeoutOverrides };
|
|
26
35
|
this.debug = debug;
|
|
27
36
|
this.buildServicesFn = buildServices ?? this.buildServices.bind(this);
|
|
28
37
|
}
|
|
@@ -58,6 +67,11 @@ class MessageBus {
|
|
|
58
67
|
self.clearTimeout(this.tickTimeout);
|
|
59
68
|
this.tickTimeout = null;
|
|
60
69
|
}
|
|
70
|
+
for (const record of this.lateDeliveries) {
|
|
71
|
+
record.settled = true;
|
|
72
|
+
self.clearTimeout(record.deadline);
|
|
73
|
+
}
|
|
74
|
+
this.lateDeliveries.clear();
|
|
61
75
|
self.removeEventListener("message", this.boundOnMessage);
|
|
62
76
|
await Promise.all(Array.from(this.handlers.values()).map((updater) => updater.stop()));
|
|
63
77
|
}
|
|
@@ -84,7 +98,8 @@ class MessageBus {
|
|
|
84
98
|
const now = Date.now();
|
|
85
99
|
for (const updater of this.handlers.values()) {
|
|
86
100
|
try {
|
|
87
|
-
const
|
|
101
|
+
const tickLabel = `${updater.messageTag}:tick`;
|
|
102
|
+
const response = await this.withTimeout(updater.tick(now), this.resolveTimeoutMs(tickLabel, updater.messageTag), tickLabel);
|
|
88
103
|
if (this.debug)
|
|
89
104
|
console.log(`[${updater.messageTag}] outgoing tick response:`, response);
|
|
90
105
|
if (response && response.length > 0) {
|
|
@@ -127,6 +142,12 @@ class MessageBus {
|
|
|
127
142
|
this.initialized = false;
|
|
128
143
|
await Promise.all(Array.from(this.handlers.values()).map((h) => h.stop().catch(() => { })));
|
|
129
144
|
}
|
|
145
|
+
// Recompute the active timeout map from scratch so a prior init's
|
|
146
|
+
// keys cannot linger after re-init with a smaller map.
|
|
147
|
+
this.messageTimeoutOverrides = {
|
|
148
|
+
...this.constructorTimeoutOverrides,
|
|
149
|
+
...(config.messageTimeouts ?? {}),
|
|
150
|
+
};
|
|
130
151
|
const services = await this.buildServicesFn(config);
|
|
131
152
|
// Start all handlers
|
|
132
153
|
for (const updater of this.handlers.values()) {
|
|
@@ -149,8 +170,9 @@ class MessageBus {
|
|
|
149
170
|
const delegatorProvider = config.delegatorUrl
|
|
150
171
|
? new delegator_1.RestDelegatorProvider(config.delegatorUrl)
|
|
151
172
|
: undefined;
|
|
152
|
-
|
|
153
|
-
|
|
173
|
+
const serialized = (0, identity_1.normalizeSerializedIdentity)(config.wallet);
|
|
174
|
+
if ((0, identity_1.isSigningSerialized)(serialized)) {
|
|
175
|
+
const identity = (0, identity_1.hydrateIdentity)(serialized);
|
|
154
176
|
const wallet = await wallet_1.Wallet.create({
|
|
155
177
|
identity,
|
|
156
178
|
arkServerUrl: config.arkServer.url,
|
|
@@ -164,23 +186,18 @@ class MessageBus {
|
|
|
164
186
|
});
|
|
165
187
|
return { wallet, arkProvider, readonlyWallet: wallet };
|
|
166
188
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return { readonlyWallet, arkProvider };
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
throw new Error("Missing privateKey or publicKey in configuration object");
|
|
183
|
-
}
|
|
189
|
+
const identity = (0, identity_1.hydrateIdentity)(serialized);
|
|
190
|
+
const readonlyWallet = await wallet_1.ReadonlyWallet.create({
|
|
191
|
+
identity,
|
|
192
|
+
arkServerUrl: config.arkServer.url,
|
|
193
|
+
arkServerPublicKey: config.arkServer.publicKey,
|
|
194
|
+
indexerUrl: config.indexerUrl,
|
|
195
|
+
esploraUrl: config.esploraUrl,
|
|
196
|
+
storage,
|
|
197
|
+
delegatorProvider,
|
|
198
|
+
watcherConfig: config.watcherConfig,
|
|
199
|
+
});
|
|
200
|
+
return { readonlyWallet, arkProvider };
|
|
184
201
|
}
|
|
185
202
|
onMessage(event) {
|
|
186
203
|
// Keep the service worker alive while async work is pending.
|
|
@@ -195,7 +212,7 @@ class MessageBus {
|
|
|
195
212
|
async processMessage(event) {
|
|
196
213
|
const { id, tag, broadcast } = event.data;
|
|
197
214
|
if (tag === "PING") {
|
|
198
|
-
event.source
|
|
215
|
+
this.deliverResponse(event.source, { id, tag: "PONG" }, { id, tag: "PONG" });
|
|
199
216
|
return;
|
|
200
217
|
}
|
|
201
218
|
if (tag === "INITIALIZE_MESSAGE_BUS") {
|
|
@@ -206,7 +223,7 @@ class MessageBus {
|
|
|
206
223
|
// performs network calls (buildServices) and handler startup
|
|
207
224
|
// that may legitimately exceed the message timeout.
|
|
208
225
|
await this.waitForInit(event.data.config);
|
|
209
|
-
event.source
|
|
226
|
+
this.deliverResponse(event.source, { id, tag }, { id, tag });
|
|
210
227
|
if (this.debug) {
|
|
211
228
|
console.log("MessageBus initialized");
|
|
212
229
|
}
|
|
@@ -219,45 +236,60 @@ class MessageBus {
|
|
|
219
236
|
// hanging forever. This happens when the browser kills and restarts
|
|
220
237
|
// the service worker — the new instance has initialized=false and
|
|
221
238
|
// messages arrive before INITIALIZE_MESSAGE_BUS is re-sent.
|
|
222
|
-
|
|
239
|
+
const fallbackTag = tag ?? "unknown";
|
|
240
|
+
this.deliverResponse(event.source, {
|
|
223
241
|
id,
|
|
224
|
-
tag:
|
|
242
|
+
tag: fallbackTag,
|
|
225
243
|
error: new errors_1.MessageBusNotInitializedError(),
|
|
226
|
-
});
|
|
244
|
+
}, { id, tag: fallbackTag });
|
|
227
245
|
return;
|
|
228
246
|
}
|
|
229
247
|
if (!id || !tag) {
|
|
230
248
|
if (this.debug)
|
|
231
249
|
console.error("Invalid message received, missing required fields:", event.data);
|
|
232
|
-
|
|
250
|
+
const fallbackTag = tag ?? "unknown";
|
|
251
|
+
this.deliverResponse(event.source, {
|
|
233
252
|
id,
|
|
234
|
-
tag:
|
|
253
|
+
tag: fallbackTag,
|
|
235
254
|
error: new TypeError("Invalid message received, missing required fields"),
|
|
236
|
-
});
|
|
255
|
+
}, { id, tag: fallbackTag });
|
|
237
256
|
return;
|
|
238
257
|
}
|
|
258
|
+
const messageType = this.extractMessageType(event.data);
|
|
239
259
|
if (broadcast) {
|
|
240
260
|
const updaters = Array.from(this.handlers.values());
|
|
241
|
-
const
|
|
261
|
+
const entries = updaters.map((updater) => {
|
|
262
|
+
const label = this.labelFor(messageType, updater.messageTag);
|
|
263
|
+
const timeoutMs = this.resolveTimeoutMs(messageType, updater.messageTag);
|
|
264
|
+
const handlerPromise = updater.handleMessage(event.data);
|
|
265
|
+
const raced = updater.isLongRunning?.(event.data)
|
|
266
|
+
? handlerPromise
|
|
267
|
+
: this.withTimeout(handlerPromise, timeoutMs, label);
|
|
268
|
+
return { updater, handlerPromise, raced };
|
|
269
|
+
});
|
|
270
|
+
const results = await Promise.allSettled(entries.map((e) => e.raced));
|
|
242
271
|
results.forEach((result, index) => {
|
|
243
|
-
const updater =
|
|
272
|
+
const { updater, handlerPromise } = entries[index];
|
|
273
|
+
const handlerTag = updater.messageTag;
|
|
274
|
+
const context = { id, tag: handlerTag, messageType };
|
|
244
275
|
if (result.status === "fulfilled") {
|
|
245
276
|
const response = result.value;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
277
|
+
// Always deliver a response so the caller's message id
|
|
278
|
+
// never goes silent. Handlers returning null/undefined
|
|
279
|
+
// get an explicit ack envelope.
|
|
280
|
+
this.deliverResponse(event.source, response ?? { id, tag: handlerTag }, context);
|
|
249
281
|
}
|
|
250
282
|
else {
|
|
251
283
|
if (this.debug)
|
|
252
|
-
console.error(`[${
|
|
253
|
-
const error = result.reason
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
284
|
+
console.error(`[${handlerTag}] handleMessage failed`, result.reason);
|
|
285
|
+
const error = toError(result.reason);
|
|
286
|
+
this.deliverResponse(event.source, { id, tag: handlerTag, error }, context);
|
|
287
|
+
// If the error was a timeout, keep watching the
|
|
288
|
+
// underlying handler and surface its eventual result
|
|
289
|
+
// under the same id.
|
|
290
|
+
if (result.reason instanceof errors_1.ServiceWorkerTimeoutError) {
|
|
291
|
+
this.attachLateDelivery(handlerPromise, event.source, id, handlerTag, messageType);
|
|
292
|
+
}
|
|
261
293
|
}
|
|
262
294
|
});
|
|
263
295
|
return;
|
|
@@ -266,35 +298,53 @@ class MessageBus {
|
|
|
266
298
|
if (!updater) {
|
|
267
299
|
if (this.debug)
|
|
268
300
|
console.warn(`[${tag}] unknown message tag, ignoring message`);
|
|
301
|
+
this.deliverResponse(event.source, {
|
|
302
|
+
id,
|
|
303
|
+
tag,
|
|
304
|
+
error: new Error(`Unknown handler tag: ${tag}`),
|
|
305
|
+
}, { id, tag, messageType });
|
|
269
306
|
return;
|
|
270
307
|
}
|
|
308
|
+
const label = this.labelFor(messageType, tag);
|
|
309
|
+
const timeoutMs = this.resolveTimeoutMs(messageType, tag);
|
|
310
|
+
const handlerPromise = updater.handleMessage(event.data);
|
|
311
|
+
const context = { id, tag, messageType };
|
|
271
312
|
try {
|
|
272
|
-
const response =
|
|
313
|
+
const response = updater.isLongRunning?.(event.data)
|
|
314
|
+
? await handlerPromise
|
|
315
|
+
: await this.withTimeout(handlerPromise, timeoutMs, label);
|
|
273
316
|
if (this.debug)
|
|
274
317
|
console.log(`[${tag}] outgoing response:`, response);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
318
|
+
// Always deliver a response so the caller's message id never
|
|
319
|
+
// goes silent. A handler returning null/undefined yields an
|
|
320
|
+
// explicit ack envelope.
|
|
321
|
+
this.deliverResponse(event.source, response ?? { id, tag }, context);
|
|
278
322
|
}
|
|
279
323
|
catch (err) {
|
|
280
324
|
if (this.debug)
|
|
281
325
|
console.error(`[${tag}] handleMessage failed`, err);
|
|
282
|
-
const error =
|
|
283
|
-
event.source
|
|
326
|
+
const error = toError(err);
|
|
327
|
+
this.deliverResponse(event.source, { id, tag, error }, context);
|
|
328
|
+
// When we abandoned the handler via timeout, keep watching it
|
|
329
|
+
// so the client's message id eventually gets a final response.
|
|
330
|
+
if (err instanceof errors_1.ServiceWorkerTimeoutError) {
|
|
331
|
+
this.attachLateDelivery(handlerPromise, event.source, id, tag, messageType);
|
|
332
|
+
}
|
|
284
333
|
}
|
|
285
334
|
}
|
|
286
335
|
/**
|
|
287
336
|
* Race `promise` against a timeout. Note: this does NOT cancel the
|
|
288
|
-
* underlying work — the original promise keeps running.
|
|
289
|
-
*
|
|
337
|
+
* underlying work — the original promise keeps running. Call
|
|
338
|
+
* `attachLateDelivery` after catching the timeout to surface the
|
|
339
|
+
* eventual result so the message id does not go silent.
|
|
290
340
|
*/
|
|
291
|
-
withTimeout(promise, label) {
|
|
292
|
-
if (
|
|
341
|
+
withTimeout(promise, timeoutMs, label) {
|
|
342
|
+
if (timeoutMs <= 0)
|
|
293
343
|
return promise;
|
|
294
344
|
return new Promise((resolve, reject) => {
|
|
295
345
|
const timer = self.setTimeout(() => {
|
|
296
|
-
reject(new errors_1.ServiceWorkerTimeoutError(`Message handler timed out after ${
|
|
297
|
-
},
|
|
346
|
+
reject(new errors_1.ServiceWorkerTimeoutError(`Message handler timed out after ${timeoutMs}ms (${label})`));
|
|
347
|
+
}, timeoutMs);
|
|
298
348
|
promise.then((val) => {
|
|
299
349
|
self.clearTimeout(timer);
|
|
300
350
|
resolve(val);
|
|
@@ -304,6 +354,97 @@ class MessageBus {
|
|
|
304
354
|
});
|
|
305
355
|
});
|
|
306
356
|
}
|
|
357
|
+
/**
|
|
358
|
+
* Extract the declared `type` from a request envelope (e.g. "SETTLE").
|
|
359
|
+
* Not every envelope carries a type (PING/INIT are special cased
|
|
360
|
+
* earlier), so this returns undefined for envelopes that lack one.
|
|
361
|
+
*/
|
|
362
|
+
extractMessageType(data) {
|
|
363
|
+
const maybeType = data.type;
|
|
364
|
+
return typeof maybeType === "string" ? maybeType : undefined;
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Resolve the timeout for an operation. Message-type overrides take
|
|
368
|
+
* precedence over handler-tag overrides, with the bus-wide default
|
|
369
|
+
* (`messageTimeoutMs`) as the final fallback.
|
|
370
|
+
*/
|
|
371
|
+
resolveTimeoutMs(messageType, handlerTag) {
|
|
372
|
+
if (messageType &&
|
|
373
|
+
Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, messageType)) {
|
|
374
|
+
return this.messageTimeoutOverrides[messageType];
|
|
375
|
+
}
|
|
376
|
+
if (Object.prototype.hasOwnProperty.call(this.messageTimeoutOverrides, handlerTag)) {
|
|
377
|
+
return this.messageTimeoutOverrides[handlerTag];
|
|
378
|
+
}
|
|
379
|
+
return this.messageTimeoutMs;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Build a human-readable label for timeout errors. Format:
|
|
383
|
+
* `"<MESSAGE_TYPE> via <HANDLER_TAG>"` when both are known, else the
|
|
384
|
+
* handler tag alone. Used so timeout errors name the operation the
|
|
385
|
+
* client actually triggered (e.g. SETTLE) rather than just the
|
|
386
|
+
* handler that received it (e.g. WALLET_UPDATER).
|
|
387
|
+
*/
|
|
388
|
+
labelFor(messageType, handlerTag) {
|
|
389
|
+
return messageType ? `${messageType} via ${handlerTag}` : handlerTag;
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Post a response to the originating client. When `source` is null
|
|
393
|
+
* (client tab closed, detached frame, etc.) the response cannot be
|
|
394
|
+
* delivered; we log the drop in debug mode so it is not invisible.
|
|
395
|
+
*/
|
|
396
|
+
deliverResponse(source, response, context) {
|
|
397
|
+
if (!source) {
|
|
398
|
+
if (this.debug)
|
|
399
|
+
console.warn(`[${context.tag}] cannot deliver response: event.source is null`, {
|
|
400
|
+
id: context.id,
|
|
401
|
+
messageType: context.messageType,
|
|
402
|
+
});
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
source.postMessage(response);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* After a handler times out the client has already received a timeout
|
|
409
|
+
* error, but the handler keeps running. Attach a follow-up so the
|
|
410
|
+
* handler's eventual result (or error) is delivered under the same
|
|
411
|
+
* message id, or — if the handler never completes within
|
|
412
|
+
* {@link LATE_DELIVERY_GRACE_MS} — an "Operation abandoned" error is
|
|
413
|
+
* sent so the client's listener (if still attached) does not hang.
|
|
414
|
+
*/
|
|
415
|
+
attachLateDelivery(handlerPromise, source, id, tag, messageType) {
|
|
416
|
+
const context = { id, tag, messageType };
|
|
417
|
+
const record = {
|
|
418
|
+
settled: false,
|
|
419
|
+
deadline: self.setTimeout(() => {
|
|
420
|
+
if (record.settled)
|
|
421
|
+
return;
|
|
422
|
+
record.settled = true;
|
|
423
|
+
this.lateDeliveries.delete(record);
|
|
424
|
+
this.deliverResponse(source, {
|
|
425
|
+
id,
|
|
426
|
+
tag,
|
|
427
|
+
error: new Error(`Operation abandoned: handler did not complete within ${LATE_DELIVERY_GRACE_MS}ms after timeout (${this.labelFor(messageType, tag)})`),
|
|
428
|
+
}, context);
|
|
429
|
+
}, LATE_DELIVERY_GRACE_MS),
|
|
430
|
+
};
|
|
431
|
+
this.lateDeliveries.add(record);
|
|
432
|
+
handlerPromise.then((response) => {
|
|
433
|
+
if (record.settled)
|
|
434
|
+
return;
|
|
435
|
+
record.settled = true;
|
|
436
|
+
self.clearTimeout(record.deadline);
|
|
437
|
+
this.lateDeliveries.delete(record);
|
|
438
|
+
this.deliverResponse(source, response ?? { id, tag }, context);
|
|
439
|
+
}, (err) => {
|
|
440
|
+
if (record.settled)
|
|
441
|
+
return;
|
|
442
|
+
record.settled = true;
|
|
443
|
+
self.clearTimeout(record.deadline);
|
|
444
|
+
this.lateDeliveries.delete(record);
|
|
445
|
+
this.deliverResponse(source, { id, tag, error: toError(err) }, context);
|
|
446
|
+
});
|
|
447
|
+
}
|
|
307
448
|
/**
|
|
308
449
|
* Returns the registered SW for the path.
|
|
309
450
|
* It uses the functions in `service-worker-manager.ts` module.
|
|
@@ -327,3 +468,6 @@ class MessageBus {
|
|
|
327
468
|
}
|
|
328
469
|
}
|
|
329
470
|
exports.MessageBus = MessageBus;
|
|
471
|
+
function toError(value) {
|
|
472
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
473
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isEventSourceError } from '../providers/utils.js';
|
|
1
2
|
/**
|
|
2
3
|
* Watches multiple contracts for virtual output state changes with resilient connection handling.
|
|
3
4
|
*
|
|
@@ -267,7 +268,12 @@ export class ContractWatcher {
|
|
|
267
268
|
// indefinitely and block the caller.
|
|
268
269
|
// Error management must be implemented to ensure the connection
|
|
269
270
|
// is restored and events are fired.
|
|
270
|
-
|
|
271
|
+
if (isEventSourceError(e)) {
|
|
272
|
+
console.debug("ContractWatcher subscription disconnected; reconnecting");
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
console.error(e);
|
|
276
|
+
}
|
|
271
277
|
this.connectionState = "disconnected";
|
|
272
278
|
this.eventCallback?.({
|
|
273
279
|
type: "connection_reset",
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { hex } from "@scure/base";
|
|
2
2
|
import { DefaultVtxo } from '../../script/default.js';
|
|
3
3
|
import { isCsvSpendable, sequenceToTimelock, timelockToSequence, } from './helpers.js';
|
|
4
|
+
import { normalizeToDescriptor, extractPubKey, } from '../../identity/descriptor.js';
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
+
* Extract pubkey bytes from a descriptor or hex string.
|
|
7
|
+
*/
|
|
8
|
+
function extractPubKeyBytes(value) {
|
|
9
|
+
return hex.decode(extractPubKey(normalizeToDescriptor(value)));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Handler for default wallet VTXOs.
|
|
6
13
|
*
|
|
7
14
|
* Default contracts use the standard forfeit + exit tapscript:
|
|
8
15
|
* - forfeit: (Alice + Server) multisig for collaborative spending
|
|
@@ -26,8 +33,8 @@ export const DefaultContractHandler = {
|
|
|
26
33
|
? sequenceToTimelock(Number(params.csvTimelock))
|
|
27
34
|
: DefaultVtxo.Script.DEFAULT_TIMELOCK;
|
|
28
35
|
return {
|
|
29
|
-
pubKey:
|
|
30
|
-
serverPubKey:
|
|
36
|
+
pubKey: extractPubKeyBytes(params.pubKey),
|
|
37
|
+
serverPubKey: extractPubKeyBytes(params.serverPubKey),
|
|
31
38
|
csvTimelock,
|
|
32
39
|
};
|
|
33
40
|
},
|