@acala-network/chopsticks 1.4.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,77 @@
1
+ import type { ApiPromise } from '@polkadot/api';
2
+ import type { AddressOrPair } from '@polkadot/api/types';
3
+ export interface ConnectBridgeHubsConfig {
4
+ /** Signs `receive_messages_proof` (on destination) and `receive_messages_delivery_proof`
5
+ * (on source). Must hold a balance on **both** chains. */
6
+ signer: AddressOrPair;
7
+ /** Pallet name overrides. Auto-detected by storage shape when omitted. */
8
+ sourceMessagesPallet?: string;
9
+ destParachainsPallet?: string;
10
+ destMessagesPallet?: string;
11
+ /** Auto-detected via `parachainInfo.parachainId` when omitted. */
12
+ sourceParaId?: number;
13
+ /** Credited as `relayer_id_at_bridged_chain`. Defaults to signer's address. */
14
+ relayerIdAtSource?: string;
15
+ /** Per-message weight upper bound. Default fits a typical XCM Transact. */
16
+ dispatchWeightPerMessage?: {
17
+ refTime: bigint;
18
+ proofSize: bigint;
19
+ };
20
+ /** Source-side `pallet_bridge_parachains` tracking the destination. Auto-detected. */
21
+ sourceParachainsPallet?: string;
22
+ /** Destination para id, used by the delivery-confirmation proofs. Auto-detected. */
23
+ destParaId?: number;
24
+ }
25
+ export interface BridgeHandle {
26
+ disconnect: () => Promise<void>;
27
+ }
28
+ /** One relayer entry's `DeliveredMessages` range. */
29
+ type RelayerEntry = {
30
+ messages?: {
31
+ begin?: number | string;
32
+ end?: number | string;
33
+ };
34
+ };
35
+ /** Raw `InboundLaneData` JSON for one lane. */
36
+ type InboundLaneJson = {
37
+ relayers?: RelayerEntry[];
38
+ lastConfirmedNonce?: number | string;
39
+ } | null;
40
+ /** `last_delivered_nonce` derived as the pallet does: last relayer entry's `messages.end`, or
41
+ * `last_confirmed_nonce` when the relayer queue is empty. */
42
+ export declare const lastDeliveredFromInbound: (v: InboundLaneJson) => bigint;
43
+ export interface UnrewardedRelayersState {
44
+ unrewardedRelayerEntries: number;
45
+ messagesInOldestEntry: bigint;
46
+ totalMessages: bigint;
47
+ lastDeliveredNonce: bigint;
48
+ }
49
+ /** `UnrewardedRelayersState` derived from an inbound lane's relayer queue exactly as the pallet's
50
+ * `impl From<&InboundLaneData>` does (the dispatch rejects any mismatch with
51
+ * `InvalidUnrewardedRelayersState`):
52
+ * - `unrewardedRelayerEntries` = number of relayer entries in the queue;
53
+ * - `totalMessages` spans the whole queue, `back.end - front.begin + 1` (NOT a per-entry sum);
54
+ * - `messagesInOldestEntry` is only the *front* entry's own size, `front.end - front.begin + 1`;
55
+ * - `lastDeliveredNonce` = `back.end`, or the passed `lastDelivered` (the lane's
56
+ * `last_confirmed_nonce`) when the queue is empty.
57
+ */
58
+ export declare const deriveUnrewardedRelayersState: (relayers: RelayerEntry[], lastDelivered: bigint) => UnrewardedRelayersState;
59
+ /**
60
+ * Relay bridge messages between two forked bridge hubs via two reactive loops, each driven by a
61
+ * chain's new heads; terminate with `handle.disconnect()`. The connector never builds blocks — it
62
+ * reads state and pushes extrinsics to pools, then reacts to the heads produced by whatever builds
63
+ * blocks (auto-build under `Instant`/`Batch`, or a host driving `dev_newBlock` under `Manual`), so
64
+ * it works identically across build modes.
65
+ *
66
+ * - **deliver**: push `receive_messages_proof` for `[last_delivered+1 .. latest_generated]` (chunked
67
+ * to MAX_MESSAGES_PER_PROOF) to the destination pool. Starting at the live `last_delivered+1` gives
68
+ * `is_obsolete` its contiguity and re-delivers nothing.
69
+ * - **confirm**: push `receive_messages_delivery_proof` to the source pool so `latest_received_nonce`
70
+ * advances and the destination prunes its relayers queue (else long runs hit the unconfirmed limit).
71
+ *
72
+ * `signer` must hold a balance on both sides.
73
+ */
74
+ export declare const connectBridgeHubs: (source: ApiPromise, destination: ApiPromise, config: ConnectBridgeHubsConfig) => Promise<BridgeHandle>;
75
+ /** Whether `api`'s runtime registers a `pallet_bridge_messages` instance. */
76
+ export declare const hasBridgeMessagesPallet: (api: ApiPromise) => boolean;
77
+ export {};
@@ -0,0 +1,409 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ function _export(target, all) {
6
+ for(var name in all)Object.defineProperty(target, name, {
7
+ enumerable: true,
8
+ get: Object.getOwnPropertyDescriptor(all, name).get
9
+ });
10
+ }
11
+ _export(exports, {
12
+ get connectBridgeHubs () {
13
+ return connectBridgeHubs;
14
+ },
15
+ get deriveUnrewardedRelayersState () {
16
+ return deriveUnrewardedRelayersState;
17
+ },
18
+ get hasBridgeMessagesPallet () {
19
+ return hasBridgeMessagesPallet;
20
+ },
21
+ get lastDeliveredFromInbound () {
22
+ return lastDeliveredFromInbound;
23
+ }
24
+ });
25
+ const _chopstickscore = require("@acala-network/chopsticks-core");
26
+ const _util = require("@polkadot/util");
27
+ const DEFAULT_DISPATCH_WEIGHT = {
28
+ refTime: 5_000_000_000n,
29
+ proofSize: 65_536n
30
+ };
31
+ // Max nonces per `receive_messages_proof`; cumulus hubs reject larger proofs as `TooManyMessagesInTheProof`.
32
+ const MAX_MESSAGES_PER_PROOF = 4096n;
33
+ // Serialize pool submissions per chain so concurrent submits don't read the same nonce and drop one.
34
+ const chainLocks = new WeakMap();
35
+ const runOnChain = (api, fn)=>{
36
+ const run = (chainLocks.get(api) ?? Promise.resolve()).catch(()=>{}).then(fn);
37
+ chainLocks.set(api, run);
38
+ return run;
39
+ };
40
+ // Submit to `api`'s pool (no-callback form resolves on pool acceptance), serialized via `runOnChain`.
41
+ const submitTx = (api, tx, signer)=>runOnChain(api, ()=>tx.signAndSend(signer, {
42
+ era: 0
43
+ }).then(()=>undefined));
44
+ // `is_obsolete` rejects an already-applied or gapped proof as `InvalidTransaction::Stale` — benign
45
+ // for the relayer (the lane already advanced), so it's matched rather than treated as a failure.
46
+ const isStaleError = (message)=>/stale/i.test(message);
47
+ // Single-slot serializer: each task runs after the previous; throws are logged under `label`.
48
+ const serialChain = (label)=>{
49
+ let tail = Promise.resolve();
50
+ return {
51
+ enqueue: (fn)=>{
52
+ tail = tail.then(fn).catch((err)=>_chopstickscore.bridgeLogger.error({
53
+ err: err.message
54
+ }, label));
55
+ },
56
+ drain: ()=>tail
57
+ };
58
+ };
59
+ // Lane id is the first (and only) key arg of an `entries()` key; polkadot.js doesn't type it, hence the cast.
60
+ const laneHexFromKey = (key)=>key.args[0].toHex();
61
+ // The three `pallet_bridge_parachains` writes that import a bridged head, so a proof taken at that
62
+ // head verifies against it. Used both ways: deliver imports the source head on the destination,
63
+ // confirm imports the destination head on the source. Keys are derived from `api`'s metadata
64
+ // (hashers and key codecs included); only the values are hand-encoded.
65
+ const importParaHead = (api, pallet, paraId, headHashHex, blockNumber, stateRoot)=>{
66
+ const query = api.query[pallet];
67
+ const headBytes = (0, _util.hexToU8a)(headHashHex);
68
+ return [
69
+ [
70
+ query.parasInfo.key(paraId),
71
+ (0, _chopstickscore.encodeParaInfo)(blockNumber, headBytes, 1)
72
+ ],
73
+ [
74
+ query.importedParaHeads.key(paraId, headHashHex),
75
+ (0, _chopstickscore.encodeParaStoredHeaderData)(blockNumber, stateRoot)
76
+ ],
77
+ [
78
+ query.importedParaHashes.key(paraId, 0),
79
+ headHashHex
80
+ ]
81
+ ];
82
+ };
83
+ const lastDeliveredFromInbound = (v)=>{
84
+ const relayers = v?.relayers ?? [];
85
+ return relayers.length ? BigInt(relayers[relayers.length - 1].messages?.end ?? 0) : BigInt(v?.lastConfirmedNonce ?? 0);
86
+ };
87
+ const deriveUnrewardedRelayersState = (relayers, lastDelivered)=>{
88
+ if (relayers.length === 0) {
89
+ return {
90
+ unrewardedRelayerEntries: 0,
91
+ messagesInOldestEntry: 0n,
92
+ totalMessages: 0n,
93
+ lastDeliveredNonce: lastDelivered
94
+ };
95
+ }
96
+ const frontBegin = BigInt(relayers[0].messages?.begin ?? 0);
97
+ const frontEnd = BigInt(relayers[0].messages?.end ?? 0);
98
+ return {
99
+ unrewardedRelayerEntries: relayers.length,
100
+ messagesInOldestEntry: frontEnd - frontBegin + 1n,
101
+ totalMessages: lastDelivered - frontBegin + 1n,
102
+ lastDeliveredNonce: lastDelivered
103
+ };
104
+ };
105
+ const connectBridgeHubs = async (source, destination, config)=>{
106
+ const sourceMessagesPallet = config.sourceMessagesPallet ?? detectSourceMessagesPallet(source);
107
+ const destParachainsPallet = config.destParachainsPallet ?? detectBridgeParachainsPallet(destination, 'destination');
108
+ const destMessagesPallet = config.destMessagesPallet ?? detectDestMessagesPallet(destination);
109
+ const dispatchWeight = config.dispatchWeightPerMessage ?? DEFAULT_DISPATCH_WEIGHT;
110
+ const signerAddress = typeof config.signer === 'string' ? config.signer : config.signer.address;
111
+ const relayerIdAtSource = config.relayerIdAtSource ?? signerAddress;
112
+ const [sourceParaId, destParaId] = await Promise.all([
113
+ config.sourceParaId ?? detectParaId(source, 'source'),
114
+ config.destParaId ?? detectParaId(destination, 'destination')
115
+ ]);
116
+ // Confirm proves the destination's inbound lane on the source, so it needs the source-side
117
+ // bridge-parachains pallet and the destination's para id (deliver's deps, reversed).
118
+ const sourceParachainsPallet = config.sourceParachainsPallet ?? detectBridgeParachainsPallet(source, 'source');
119
+ // camelCase pallet names for `api.query`/`api.tx` lookups.
120
+ const sourceMessages = (0, _util.stringCamelCase)(sourceMessagesPallet);
121
+ const destMessages = (0, _util.stringCamelCase)(destMessagesPallet);
122
+ const sourceParachains = (0, _util.stringCamelCase)(sourceParachainsPallet);
123
+ const destParachains = (0, _util.stringCamelCase)(destParachainsPallet);
124
+ // lane -> source `latest_generated_nonce` observed on source heads (deliver up to this).
125
+ const generated = new Map();
126
+ // lane -> `noncesEnd` of a delivery pushed to the destination pool but not yet observed applied.
127
+ const inFlight = new Map();
128
+ // lane -> highest destination `last_delivered_nonce` already confirmed back to source.
129
+ const confirmed = new Map();
130
+ // lane -> highest destination `last_delivered_nonce` observed by the deliver loop. It's
131
+ // monotonic, so it lets idle heads skip the per-lane inbound query.
132
+ const deliveredCache = new Map();
133
+ // Latest observed source head; outbound messages are proven against it (they exist there, since
134
+ // `latest_generated >= to`). Set on connect and refreshed on every source head.
135
+ let latestSourceHash;
136
+ let latestSourceNumber = 0;
137
+ // Push one chunk `[from..=to]` to the destination pool, proven against the given source head
138
+ // (the messages exist there since `latest_generated >= to`). Outbound lane state proven alongside.
139
+ const deliverRange = async (laneHex, from, to, sourceHash, sourceNumber)=>{
140
+ // `OutboundMessages` is keyed by `MessageKey { lane_id, nonce }`, passed as one struct arg.
141
+ const outboundMessages = source.query[sourceMessages].outboundMessages;
142
+ const keys = [
143
+ source.query[sourceMessages].outboundLanes.key(laneHex)
144
+ ];
145
+ for(let n = from; n <= to; n++){
146
+ keys.push(outboundMessages.key({
147
+ laneId: laneHex,
148
+ nonce: n
149
+ }));
150
+ }
151
+ // `dev_getReadProof` (not spec `state_getReadProof`) returns the recomputed `stateRoot` that the
152
+ // verifier checks against — it diverges from `header.state_root` once local overrides are applied.
153
+ const proof = await rawRpc(source, 'dev_getReadProof', [
154
+ keys,
155
+ sourceHash
156
+ ]);
157
+ const setStoragePayload = importParaHead(destination, destParachains, sourceParaId, sourceHash, sourceNumber, (0, _util.hexToU8a)(proof.stateRoot));
158
+ await rawRpc(destination, 'dev_setStorage', [
159
+ setStoragePayload
160
+ ]);
161
+ const count = Number(to - from + 1n);
162
+ const tx = destination.tx[destMessages].receiveMessagesProof(relayerIdAtSource, {
163
+ bridgedHeaderHash: sourceHash,
164
+ storageProof: proof.proof,
165
+ lane: laneHex,
166
+ noncesStart: from,
167
+ noncesEnd: to
168
+ }, count, {
169
+ refTime: dispatchWeight.refTime * BigInt(count),
170
+ proofSize: dispatchWeight.proofSize * BigInt(count)
171
+ });
172
+ await submitTx(destination, tx, config.signer);
173
+ _chopstickscore.bridgeLogger.info({
174
+ lane: laneHex,
175
+ range: `${from}..=${to}`,
176
+ sourceBlock: sourceNumber
177
+ }, 'pushed bridge delivery');
178
+ };
179
+ // Push the next chunk for a lane if ready. Reads the live `last_delivered_nonce` so the proof
180
+ // starts at `last_delivered+1` (is_obsolete contiguity); an in-flight chunk blocks further pushes
181
+ // until a destination head shows it applied.
182
+ const tryDeliverLane = async (laneHex)=>{
183
+ const sourceHash = latestSourceHash;
184
+ const sourceNumber = latestSourceNumber;
185
+ if (sourceHash === undefined) return;
186
+ const generatedNonce = generated.get(laneHex) ?? 0n;
187
+ // Idle short-circuit: `last_delivered_nonce` is monotonic, so when the cached value already
188
+ // covers everything generated and nothing is in flight, skip the inbound query entirely.
189
+ if (generatedNonce <= (deliveredCache.get(laneHex) ?? 0n) && !inFlight.has(laneHex)) return;
190
+ const delivered = lastDeliveredFromInbound((await destination.query[destMessages].inboundLanes(laneHex)).toJSON());
191
+ deliveredCache.set(laneHex, delivered);
192
+ const pending = inFlight.get(laneHex);
193
+ if (pending !== undefined) {
194
+ if (delivered >= pending) inFlight.delete(laneHex);
195
+ else return; // previous chunk not applied yet — a further push would gap
196
+ }
197
+ if (generatedNonce <= delivered) return; // nothing new to deliver
198
+ const from = delivered + 1n;
199
+ const lastInChunk = from + MAX_MESSAGES_PER_PROOF - 1n;
200
+ const to = lastInChunk < generatedNonce ? lastInChunk : generatedNonce;
201
+ await deliverRange(laneHex, from, to, sourceHash, sourceNumber);
202
+ inFlight.set(laneHex, to);
203
+ };
204
+ // On any head, push whatever is currently deliverable on each known lane.
205
+ const syncDeliveries = async ()=>{
206
+ for (const laneHex of generated.keys()){
207
+ try {
208
+ await tryDeliverLane(laneHex);
209
+ } catch (err) {
210
+ const message = err.message;
211
+ inFlight.delete(laneHex); // allow a re-push on the next head
212
+ // a `stale` push means our `last_delivered` view lagged the chain — benign, just retry
213
+ if (isStaleError(message)) {
214
+ _chopstickscore.bridgeLogger.debug({
215
+ lane: laneHex
216
+ }, 'delivery push stale; will retry');
217
+ } else {
218
+ _chopstickscore.bridgeLogger.warn({
219
+ err: message,
220
+ lane: laneHex
221
+ }, 'delivery push failed; will retry');
222
+ }
223
+ }
224
+ }
225
+ };
226
+ // Relay one lane's confirmation to `source`: prove the destination's `InboundLanes[lane]` (at
227
+ // `destHashHex`, where the delivery is applied) and submit `receive_messages_delivery_proof`,
228
+ // advancing the source's `latest_received_nonce`. Mirror of deliver.
229
+ const confirm = async (destHashHex, destBlockNumber, lane)=>{
230
+ const { laneHex, relayers, lastDelivered } = lane;
231
+ // Idempotency guard: skip if the source already covers `lastDelivered` (else `is_obsolete`
232
+ // rejects the re-submit as stale).
233
+ const sourceReceived = BigInt((await source.query[sourceMessages].outboundLanes(laneHex)).toJSON()?.latestReceivedNonce ?? 0);
234
+ if (lastDelivered <= sourceReceived) return;
235
+ const relayersState = deriveUnrewardedRelayersState(relayers, lastDelivered);
236
+ const proof = await rawRpc(destination, 'dev_getReadProof', [
237
+ [
238
+ destination.query[destMessages].inboundLanes.key(laneHex)
239
+ ],
240
+ destHashHex
241
+ ]);
242
+ const setStoragePayload = importParaHead(source, sourceParachains, destParaId, destHashHex, destBlockNumber, (0, _util.hexToU8a)(proof.stateRoot));
243
+ await rawRpc(source, 'dev_setStorage', [
244
+ setStoragePayload
245
+ ]);
246
+ const tx = source.tx[sourceMessages].receiveMessagesDeliveryProof({
247
+ bridgedHeaderHash: destHashHex,
248
+ storageProof: proof.proof,
249
+ lane: laneHex
250
+ }, relayersState);
251
+ await submitTx(source, tx, config.signer);
252
+ _chopstickscore.bridgeLogger.info({
253
+ lane: laneHex,
254
+ lastDelivered: lastDelivered.toString(),
255
+ destBlock: destBlockNumber
256
+ }, 'confirmed bridge deliveries');
257
+ };
258
+ // Per-lane `latest_generated_nonce` from `OutboundLanes` at a pinned source block.
259
+ const readOutboundLanes = async (apiAt)=>{
260
+ const entries = await apiAt.query[sourceMessages].outboundLanes.entries();
261
+ return entries.map(([key, valueCodec])=>{
262
+ const v = valueCodec.toJSON();
263
+ return [
264
+ laneHexFromKey(key),
265
+ BigInt(v?.latestGeneratedNonce ?? 0)
266
+ ];
267
+ });
268
+ };
269
+ // Per-lane `InboundLaneData` from `InboundLanes` at a pinned destination block, with
270
+ // `last_delivered_nonce` derived as the pallet does (see `lastDeliveredFromInbound`).
271
+ const readInboundLanes = async (apiAt)=>{
272
+ const entries = await apiAt.query[destMessages].inboundLanes.entries();
273
+ return entries.map(([key, valueCodec])=>{
274
+ const v = valueCodec.toJSON();
275
+ return {
276
+ laneHex: laneHexFromKey(key),
277
+ relayers: v?.relayers ?? [],
278
+ lastDelivered: lastDeliveredFromInbound(v)
279
+ };
280
+ });
281
+ };
282
+ // On a source head: pin it as the proving head and update each lane's generated high-water mark.
283
+ // Read at the header's own hash so a later proof attests exactly the nonces extant at that head.
284
+ const refreshGenerated = async (sourceHeader)=>{
285
+ const sourceHashHex = sourceHeader.hash.toHex();
286
+ try {
287
+ const lanes = await readOutboundLanes(await source.at(sourceHashHex));
288
+ latestSourceHash = sourceHashHex;
289
+ latestSourceNumber = sourceHeader.number.toNumber();
290
+ for (const [laneHex, latestGenerated] of lanes)generated.set(laneHex, latestGenerated);
291
+ } catch (err) {
292
+ _chopstickscore.bridgeLogger.warn({
293
+ err: err.message,
294
+ sourceHash: sourceHashHex
295
+ }, 'outboundLanes enumeration failed');
296
+ }
297
+ };
298
+ const pumpConfirm = async (destHeader)=>{
299
+ const destHashHex = destHeader.hash.toHex();
300
+ const destBlockNumber = destHeader.number.toNumber();
301
+ let lanes;
302
+ try {
303
+ lanes = await readInboundLanes(await destination.at(destHashHex));
304
+ } catch (err) {
305
+ _chopstickscore.bridgeLogger.warn({
306
+ err: err.message,
307
+ destHash: destHashHex
308
+ }, 'inboundLanes enumeration failed');
309
+ return;
310
+ }
311
+ for (const lane of lanes){
312
+ if (lane.lastDelivered <= (confirmed.get(lane.laneHex) ?? 0n)) continue;
313
+ try {
314
+ await confirm(destHashHex, destBlockNumber, lane);
315
+ confirmed.set(lane.laneHex, lane.lastDelivered);
316
+ } catch (err) {
317
+ const message = err.message;
318
+ // A `stale` rejection is benign: the source lane already advanced (a competing
319
+ // confirmation, or our own already-applied submit racing the read), so mark it done.
320
+ if (isStaleError(message)) {
321
+ confirmed.set(lane.laneHex, lane.lastDelivered);
322
+ _chopstickscore.bridgeLogger.debug({
323
+ lane: lane.laneHex
324
+ }, 'confirmation already applied (stale); skipping');
325
+ } else {
326
+ // Leave `confirmed` un-advanced so the next destination head retries.
327
+ _chopstickscore.bridgeLogger.warn({
328
+ err: message,
329
+ lane: lane.laneHex
330
+ }, 'confirmation failed; will retry');
331
+ }
332
+ }
333
+ }
334
+ };
335
+ // Delivery self-seeds (live `last_delivered` + `refreshGenerated` on the first source head), so
336
+ // only confirm needs a baseline: pin `confirmed` to the current `last_delivered` so a fork's
337
+ // pre-existing delivered-but-unconfirmed backlog isn't re-confirmed.
338
+ try {
339
+ const destHead = await destination.rpc.chain.getBlockHash();
340
+ for (const lane of (await readInboundLanes(await destination.at(destHead)))){
341
+ confirmed.set(lane.laneHex, lane.lastDelivered);
342
+ }
343
+ } catch (err) {
344
+ _chopstickscore.bridgeLogger.warn({
345
+ err: err.message
346
+ }, 'confirmation baseline seed failed; lanes baseline on first head');
347
+ }
348
+ // Delivery is serialized across both head streams (source heads update generated, destination
349
+ // heads observe applied chunks); confirmation is its own queue on destination heads.
350
+ const deliverQueue = serialChain('unexpected deliver error');
351
+ const confirmQueue = serialChain('unexpected confirm-pump error');
352
+ const unsubSource = await source.rpc.chain.subscribeNewHeads((header)=>{
353
+ deliverQueue.enqueue(async ()=>{
354
+ await refreshGenerated(header);
355
+ await syncDeliveries();
356
+ });
357
+ });
358
+ const unsubDest = await destination.rpc.chain.subscribeNewHeads((header)=>{
359
+ // A destination block may have applied an in-flight chunk → push the next one.
360
+ deliverQueue.enqueue(()=>syncDeliveries());
361
+ confirmQueue.enqueue(()=>pumpConfirm(header));
362
+ });
363
+ _chopstickscore.bridgeLogger.info({
364
+ sourceMessagesPallet,
365
+ destParachainsPallet,
366
+ destMessagesPallet,
367
+ sourceParaId,
368
+ sourceParachainsPallet,
369
+ destParaId
370
+ }, 'bridge connector started');
371
+ return {
372
+ async disconnect () {
373
+ unsubSource();
374
+ unsubDest();
375
+ await Promise.all([
376
+ deliverQueue.drain(),
377
+ confirmQueue.drain()
378
+ ]);
379
+ _chopstickscore.bridgeLogger.info('bridge connector stopped');
380
+ }
381
+ };
382
+ };
383
+ const rawRpc = async (api, method, params)=>{
384
+ const provider = api._rpcCore?.provider;
385
+ if (!provider?.send) throw new Error(`connectBridgeHubs: cannot access provider for ${method}`);
386
+ return provider.send(method, params);
387
+ };
388
+ /** Storage-shape predicate for a `pallet_bridge_messages` instance. */ const isBridgeMessagesPallet = (pallet)=>!!(pallet?.outboundLanes && pallet?.outboundMessages);
389
+ const hasBridgeMessagesPallet = (api)=>Object.values(api.query).some(isBridgeMessagesPallet);
390
+ /**
391
+ * Find the unique pallet in `namespace` matching `predicate`, returning its PascalCase
392
+ * name. Throws a remediation-pointing error on zero or multiple matches.
393
+ */ const detectPallet = (namespace, predicate, kind, label, overrideField)=>{
394
+ const matches = Object.keys(namespace).filter((key)=>predicate(namespace[key]));
395
+ if (matches.length === 0) throw new Error(`connectBridgeHubs: no pallet_bridge_${kind} instance on ${label}`);
396
+ if (matches.length > 1) {
397
+ const names = matches.map(_util.stringPascalCase).join(', ');
398
+ throw new Error(`connectBridgeHubs: ${label} has multiple bridge-${kind} instances (${names}); set ${overrideField}`);
399
+ }
400
+ return (0, _util.stringPascalCase)(matches[0]);
401
+ };
402
+ const detectSourceMessagesPallet = (api)=>detectPallet(api.query, isBridgeMessagesPallet, 'messages', 'source', 'sourceMessagesPallet');
403
+ const detectDestMessagesPallet = (api)=>detectPallet(api.tx, (p)=>p?.receiveMessagesProof, 'messages', 'destination', 'destMessagesPallet');
404
+ const detectBridgeParachainsPallet = (api, label)=>detectPallet(api.query, (p)=>p?.parasInfo && p?.importedParaHeads, 'parachains', label, label === 'source' ? 'sourceParachainsPallet' : 'destParachainsPallet');
405
+ const detectParaId = async (api, label)=>{
406
+ const idCodec = await api.query.parachainInfo?.parachainId?.();
407
+ if (idCodec) return idCodec.toNumber();
408
+ throw new Error(`connectBridgeHubs: cannot auto-detect ${label === 'source' ? 'sourceParaId' : 'destParaId'}`);
409
+ };
package/dist/cjs/cli.js CHANGED
@@ -3,11 +3,14 @@ Object.defineProperty(exports, "__esModule", {
3
3
  value: true
4
4
  });
5
5
  const _chopstickscore = require("@acala-network/chopsticks-core");
6
+ const _api = require("@polkadot/api");
7
+ const _utilcrypto = require("@polkadot/util-crypto");
6
8
  const _dotenv = require("dotenv");
7
9
  const _lodash = /*#__PURE__*/ _interop_require_default(require("lodash"));
8
10
  const _yargs = /*#__PURE__*/ _interop_require_default(require("yargs"));
9
11
  const _helpers = require("yargs/helpers");
10
12
  const _zod = require("zod");
13
+ const _bridge = require("./bridge.js");
11
14
  const _index = require("./index.js");
12
15
  const _index1 = require("./plugins/index.js");
13
16
  const _index2 = require("./schema/index.js");
@@ -17,6 +20,51 @@ function _interop_require_default(obj) {
17
20
  };
18
21
  }
19
22
  (0, _dotenv.config)();
23
+ /** Fork relay (optional) + parachains and wire them with UMP/DMP/HRMP — the `xcm` command's core. */ const setupNetwork = async (relayConfigRef, parachainConfigRefs)=>{
24
+ const parachains = [];
25
+ for (const configRef of parachainConfigRefs){
26
+ const { chain, addr } = await (0, _index.setupWithServer)(await (0, _index2.fetchConfig)(configRef));
27
+ parachains.push({
28
+ chain,
29
+ addr,
30
+ configRef
31
+ });
32
+ }
33
+ if (parachains.length > 1) {
34
+ await (0, _chopstickscore.connectParachains)(parachains.map((c)=>c.chain), _chopstickscore.environment.DISABLE_AUTO_HRMP);
35
+ }
36
+ if (relayConfigRef) {
37
+ const { chain: relay } = await (0, _index.setupWithServer)(await (0, _index2.fetchConfig)(relayConfigRef));
38
+ for (const c of parachains)await (0, _chopstickscore.connectVertical)(relay, c.chain);
39
+ }
40
+ return parachains;
41
+ };
42
+ /** One side of a bridged setup: an xcm network plus an `ApiPromise` per parachain. */ const setupBridgeSide = async (relayConfigRef, parachainConfigRefs)=>{
43
+ const parachains = await setupNetwork(relayConfigRef, parachainConfigRefs);
44
+ return Promise.all(parachains.map(async ({ chain, addr, configRef })=>{
45
+ const url = `ws://${addr}`;
46
+ const api = await _api.ApiPromise.create({
47
+ provider: new _api.WsProvider(url, 3_000),
48
+ noInitWarn: true
49
+ });
50
+ return {
51
+ chain,
52
+ api,
53
+ url,
54
+ configRef
55
+ };
56
+ }));
57
+ };
58
+ /** Pick the parachain whose runtime registers `pallet_bridge_messages`. */ const findBridgeHub = (side, label)=>{
59
+ const candidates = side.filter((c)=>(0, _bridge.hasBridgeMessagesPallet)(c.api));
60
+ if (candidates.length === 0) {
61
+ throw new Error(`chopsticks bridge: no ${label}-side parachain hosts pallet_bridge_messages. ` + `Provided: ${side.map((c)=>c.configRef).join(', ')}. Include a bridge-hub config.`);
62
+ }
63
+ if (candidates.length > 1) {
64
+ throw new Error(`chopsticks bridge: multiple ${label}-side parachains host pallet_bridge_messages (${candidates.map((c)=>c.configRef).join(', ')}). Cannot disambiguate — provide only one bridge hub per side.`);
65
+ }
66
+ return candidates[0];
67
+ };
20
68
  const processArgv = async (argv)=>{
21
69
  try {
22
70
  if (argv.unsafeRpcMethods) {
@@ -51,20 +99,61 @@ const commands = (0, _yargs.default)((0, _helpers.hideBin)(process.argv)).script
51
99
  required: true
52
100
  }
53
101
  }).alias('relaychain', 'r').alias('parachain', 'p'), async (argv)=>{
54
- const parachains = [];
55
- for (const config of argv.parachain){
56
- const { chain } = await (0, _index.setupWithServer)(await (0, _index2.fetchConfig)(config));
57
- parachains.push(chain);
58
- }
59
- if (parachains.length > 1) {
60
- await (0, _chopstickscore.connectParachains)(parachains, _chopstickscore.environment.DISABLE_AUTO_HRMP);
61
- }
62
- if (argv.relaychain) {
63
- const { chain: relaychain } = await (0, _index.setupWithServer)(await (0, _index2.fetchConfig)(argv.relaychain));
64
- for (const parachain of parachains){
65
- await (0, _chopstickscore.connectVertical)(relaychain, parachain);
102
+ await setupNetwork(argv.relaychain, argv.parachain);
103
+ }).command('bridge', 'Bridged-XCM setup: two ecosystems wired with pallet_bridge_messages in both directions', (yargs)=>yargs.options({
104
+ 'left-relaychain': {
105
+ desc: 'Left-side relaychain config (named or path). Example: westend',
106
+ string: true
107
+ },
108
+ 'left-parachain': {
109
+ desc: 'Left-side parachain config(s). One of them must host pallet_bridge_messages.',
110
+ type: 'array',
111
+ string: true,
112
+ required: true
113
+ },
114
+ 'right-relaychain': {
115
+ desc: 'Right-side relaychain config (named or path). Example: rococo',
116
+ string: true
117
+ },
118
+ 'right-parachain': {
119
+ desc: 'Right-side parachain config(s). One of them must host pallet_bridge_messages.',
120
+ type: 'array',
121
+ string: true,
122
+ required: true
123
+ },
124
+ 'bridge-signer-uri': {
125
+ desc: 'Relayer URI for the forward direction (left → right). Submits receive_messages_proof / _delivery_proof.',
126
+ string: true,
127
+ default: '//Alice'
128
+ },
129
+ 'bridge-reverse-signer-uri': {
130
+ desc: 'Relayer URI for the reverse direction (right → left). Defaults to <bridge-signer-uri>//reverse. Must differ from the forward relayer so their nonces on the shared hubs do not collide.',
131
+ string: true
66
132
  }
67
- }
133
+ }).alias('left-relaychain', 'r').alias('left-parachain', 'p').alias('right-relaychain', 'R').alias('right-parachain', 'P'), async (argv)=>{
134
+ await (0, _utilcrypto.cryptoWaitReady)();
135
+ const left = await setupBridgeSide(argv['left-relaychain'], argv['left-parachain']);
136
+ const right = await setupBridgeSide(argv['right-relaychain'], argv['right-parachain']);
137
+ const leftBh = findBridgeHub(left, 'left');
138
+ const rightBh = findBridgeHub(right, 'right');
139
+ // Each direction needs its OWN relayer account. Both connectors submit relayer txs to the
140
+ // same two hubs (the forward direction's confirmations and the reverse direction's deliveries
141
+ // both land on a given hub), so a shared signer collides on its nonce and one direction's tx
142
+ // is silently dropped. Distinct accounts give each direction its own nonce sequence.
143
+ const keyring = new _api.Keyring({
144
+ type: 'sr25519'
145
+ });
146
+ const forwardSigner = keyring.addFromUri(argv['bridge-signer-uri']);
147
+ const reverseSigner = keyring.addFromUri(argv['bridge-reverse-signer-uri'] ?? `${argv['bridge-signer-uri']}//reverse`);
148
+ await Promise.all([
149
+ (0, _bridge.connectBridgeHubs)(leftBh.api, rightBh.api, {
150
+ signer: forwardSigner
151
+ }),
152
+ (0, _bridge.connectBridgeHubs)(rightBh.api, leftBh.api, {
153
+ signer: reverseSigner
154
+ })
155
+ ]);
156
+ console.log(`Bridge connected:\n left bridge-hub @ ${leftBh.url}\n right bridge-hub @ ${rightBh.url}\n` + `Relayers (fund BOTH on BOTH hubs): forward ${forwardSigner.address}, reverse ${reverseSigner.address}. ` + 'They push proofs to each hub; blocks are produced by the hubs (build mode) or by you (dev_newBlock).');
68
157
  }).strict().help().alias('help', 'h').alias('version', 'v').alias('config', 'c').alias('endpoint', 'e').alias('port', 'p').alias('block', 'b').alias('unsafe-rpc-methods', 'ur').alias('import-storage', 's').alias('wasm-override', 'w').usage('Usage: $0 <command> [options]').example('$0', '-c acala').showHelpOnFail(false);
69
158
  if (!_chopstickscore.environment.DISABLE_PLUGINS) {
70
159
  (0, _index1.pluginExtendCli)(commands.config('config', 'Path to config file with default options', ()=>({}))).then(()=>commands.parse());
@@ -1,4 +1,5 @@
1
1
  import '@polkadot/api-augment';
2
2
  export * from '@acala-network/chopsticks-core';
3
+ export * from './bridge.js';
3
4
  export { fetchConfig } from './schema/index.js';
4
5
  export { setupWithServer } from './setup-with-server.js';
package/dist/cjs/index.js CHANGED
@@ -18,6 +18,7 @@ _export(exports, {
18
18
  });
19
19
  require("@polkadot/api-augment");
20
20
  _export_star(require("@acala-network/chopsticks-core"), exports);
21
+ _export_star(require("./bridge.js"), exports);
21
22
  const _index = require("./schema/index.js");
22
23
  const _setupwithserver = require("./setup-with-server.js");
23
24
  function _export_star(from, to) {
@@ -0,0 +1,77 @@
1
+ import type { ApiPromise } from '@polkadot/api';
2
+ import type { AddressOrPair } from '@polkadot/api/types';
3
+ export interface ConnectBridgeHubsConfig {
4
+ /** Signs `receive_messages_proof` (on destination) and `receive_messages_delivery_proof`
5
+ * (on source). Must hold a balance on **both** chains. */
6
+ signer: AddressOrPair;
7
+ /** Pallet name overrides. Auto-detected by storage shape when omitted. */
8
+ sourceMessagesPallet?: string;
9
+ destParachainsPallet?: string;
10
+ destMessagesPallet?: string;
11
+ /** Auto-detected via `parachainInfo.parachainId` when omitted. */
12
+ sourceParaId?: number;
13
+ /** Credited as `relayer_id_at_bridged_chain`. Defaults to signer's address. */
14
+ relayerIdAtSource?: string;
15
+ /** Per-message weight upper bound. Default fits a typical XCM Transact. */
16
+ dispatchWeightPerMessage?: {
17
+ refTime: bigint;
18
+ proofSize: bigint;
19
+ };
20
+ /** Source-side `pallet_bridge_parachains` tracking the destination. Auto-detected. */
21
+ sourceParachainsPallet?: string;
22
+ /** Destination para id, used by the delivery-confirmation proofs. Auto-detected. */
23
+ destParaId?: number;
24
+ }
25
+ export interface BridgeHandle {
26
+ disconnect: () => Promise<void>;
27
+ }
28
+ /** One relayer entry's `DeliveredMessages` range. */
29
+ type RelayerEntry = {
30
+ messages?: {
31
+ begin?: number | string;
32
+ end?: number | string;
33
+ };
34
+ };
35
+ /** Raw `InboundLaneData` JSON for one lane. */
36
+ type InboundLaneJson = {
37
+ relayers?: RelayerEntry[];
38
+ lastConfirmedNonce?: number | string;
39
+ } | null;
40
+ /** `last_delivered_nonce` derived as the pallet does: last relayer entry's `messages.end`, or
41
+ * `last_confirmed_nonce` when the relayer queue is empty. */
42
+ export declare const lastDeliveredFromInbound: (v: InboundLaneJson) => bigint;
43
+ export interface UnrewardedRelayersState {
44
+ unrewardedRelayerEntries: number;
45
+ messagesInOldestEntry: bigint;
46
+ totalMessages: bigint;
47
+ lastDeliveredNonce: bigint;
48
+ }
49
+ /** `UnrewardedRelayersState` derived from an inbound lane's relayer queue exactly as the pallet's
50
+ * `impl From<&InboundLaneData>` does (the dispatch rejects any mismatch with
51
+ * `InvalidUnrewardedRelayersState`):
52
+ * - `unrewardedRelayerEntries` = number of relayer entries in the queue;
53
+ * - `totalMessages` spans the whole queue, `back.end - front.begin + 1` (NOT a per-entry sum);
54
+ * - `messagesInOldestEntry` is only the *front* entry's own size, `front.end - front.begin + 1`;
55
+ * - `lastDeliveredNonce` = `back.end`, or the passed `lastDelivered` (the lane's
56
+ * `last_confirmed_nonce`) when the queue is empty.
57
+ */
58
+ export declare const deriveUnrewardedRelayersState: (relayers: RelayerEntry[], lastDelivered: bigint) => UnrewardedRelayersState;
59
+ /**
60
+ * Relay bridge messages between two forked bridge hubs via two reactive loops, each driven by a
61
+ * chain's new heads; terminate with `handle.disconnect()`. The connector never builds blocks — it
62
+ * reads state and pushes extrinsics to pools, then reacts to the heads produced by whatever builds
63
+ * blocks (auto-build under `Instant`/`Batch`, or a host driving `dev_newBlock` under `Manual`), so
64
+ * it works identically across build modes.
65
+ *
66
+ * - **deliver**: push `receive_messages_proof` for `[last_delivered+1 .. latest_generated]` (chunked
67
+ * to MAX_MESSAGES_PER_PROOF) to the destination pool. Starting at the live `last_delivered+1` gives
68
+ * `is_obsolete` its contiguity and re-delivers nothing.
69
+ * - **confirm**: push `receive_messages_delivery_proof` to the source pool so `latest_received_nonce`
70
+ * advances and the destination prunes its relayers queue (else long runs hit the unconfirmed limit).
71
+ *
72
+ * `signer` must hold a balance on both sides.
73
+ */
74
+ export declare const connectBridgeHubs: (source: ApiPromise, destination: ApiPromise, config: ConnectBridgeHubsConfig) => Promise<BridgeHandle>;
75
+ /** Whether `api`'s runtime registers a `pallet_bridge_messages` instance. */
76
+ export declare const hasBridgeMessagesPallet: (api: ApiPromise) => boolean;
77
+ export {};
@@ -0,0 +1,408 @@
1
+ import { bridgeLogger, encodeParaInfo, encodeParaStoredHeaderData } from '@acala-network/chopsticks-core';
2
+ import { hexToU8a, stringCamelCase, stringPascalCase } from '@polkadot/util';
3
+ const DEFAULT_DISPATCH_WEIGHT = {
4
+ refTime: 5_000_000_000n,
5
+ proofSize: 65_536n
6
+ };
7
+ // Max nonces per `receive_messages_proof`; cumulus hubs reject larger proofs as `TooManyMessagesInTheProof`.
8
+ const MAX_MESSAGES_PER_PROOF = 4096n;
9
+ // Serialize pool submissions per chain so concurrent submits don't read the same nonce and drop one.
10
+ const chainLocks = new WeakMap();
11
+ const runOnChain = (api, fn)=>{
12
+ const run = (chainLocks.get(api) ?? Promise.resolve()).catch(()=>{}).then(fn);
13
+ chainLocks.set(api, run);
14
+ return run;
15
+ };
16
+ // Submit to `api`'s pool (no-callback form resolves on pool acceptance), serialized via `runOnChain`.
17
+ const submitTx = (api, tx, signer)=>runOnChain(api, ()=>tx.signAndSend(signer, {
18
+ era: 0
19
+ }).then(()=>undefined));
20
+ // `is_obsolete` rejects an already-applied or gapped proof as `InvalidTransaction::Stale` — benign
21
+ // for the relayer (the lane already advanced), so it's matched rather than treated as a failure.
22
+ const isStaleError = (message)=>/stale/i.test(message);
23
+ // Single-slot serializer: each task runs after the previous; throws are logged under `label`.
24
+ const serialChain = (label)=>{
25
+ let tail = Promise.resolve();
26
+ return {
27
+ enqueue: (fn)=>{
28
+ tail = tail.then(fn).catch((err)=>bridgeLogger.error({
29
+ err: err.message
30
+ }, label));
31
+ },
32
+ drain: ()=>tail
33
+ };
34
+ };
35
+ // Lane id is the first (and only) key arg of an `entries()` key; polkadot.js doesn't type it, hence the cast.
36
+ const laneHexFromKey = (key)=>key.args[0].toHex();
37
+ // The three `pallet_bridge_parachains` writes that import a bridged head, so a proof taken at that
38
+ // head verifies against it. Used both ways: deliver imports the source head on the destination,
39
+ // confirm imports the destination head on the source. Keys are derived from `api`'s metadata
40
+ // (hashers and key codecs included); only the values are hand-encoded.
41
+ const importParaHead = (api, pallet, paraId, headHashHex, blockNumber, stateRoot)=>{
42
+ const query = api.query[pallet];
43
+ const headBytes = hexToU8a(headHashHex);
44
+ return [
45
+ [
46
+ query.parasInfo.key(paraId),
47
+ encodeParaInfo(blockNumber, headBytes, 1)
48
+ ],
49
+ [
50
+ query.importedParaHeads.key(paraId, headHashHex),
51
+ encodeParaStoredHeaderData(blockNumber, stateRoot)
52
+ ],
53
+ [
54
+ query.importedParaHashes.key(paraId, 0),
55
+ headHashHex
56
+ ]
57
+ ];
58
+ };
59
+ /** `last_delivered_nonce` derived as the pallet does: last relayer entry's `messages.end`, or
60
+ * `last_confirmed_nonce` when the relayer queue is empty. */ export const lastDeliveredFromInbound = (v)=>{
61
+ const relayers = v?.relayers ?? [];
62
+ return relayers.length ? BigInt(relayers[relayers.length - 1].messages?.end ?? 0) : BigInt(v?.lastConfirmedNonce ?? 0);
63
+ };
64
+ /** `UnrewardedRelayersState` derived from an inbound lane's relayer queue exactly as the pallet's
65
+ * `impl From<&InboundLaneData>` does (the dispatch rejects any mismatch with
66
+ * `InvalidUnrewardedRelayersState`):
67
+ * - `unrewardedRelayerEntries` = number of relayer entries in the queue;
68
+ * - `totalMessages` spans the whole queue, `back.end - front.begin + 1` (NOT a per-entry sum);
69
+ * - `messagesInOldestEntry` is only the *front* entry's own size, `front.end - front.begin + 1`;
70
+ * - `lastDeliveredNonce` = `back.end`, or the passed `lastDelivered` (the lane's
71
+ * `last_confirmed_nonce`) when the queue is empty.
72
+ */ export const deriveUnrewardedRelayersState = (relayers, lastDelivered)=>{
73
+ if (relayers.length === 0) {
74
+ return {
75
+ unrewardedRelayerEntries: 0,
76
+ messagesInOldestEntry: 0n,
77
+ totalMessages: 0n,
78
+ lastDeliveredNonce: lastDelivered
79
+ };
80
+ }
81
+ const frontBegin = BigInt(relayers[0].messages?.begin ?? 0);
82
+ const frontEnd = BigInt(relayers[0].messages?.end ?? 0);
83
+ return {
84
+ unrewardedRelayerEntries: relayers.length,
85
+ messagesInOldestEntry: frontEnd - frontBegin + 1n,
86
+ totalMessages: lastDelivered - frontBegin + 1n,
87
+ lastDeliveredNonce: lastDelivered
88
+ };
89
+ };
90
+ /**
91
+ * Relay bridge messages between two forked bridge hubs via two reactive loops, each driven by a
92
+ * chain's new heads; terminate with `handle.disconnect()`. The connector never builds blocks — it
93
+ * reads state and pushes extrinsics to pools, then reacts to the heads produced by whatever builds
94
+ * blocks (auto-build under `Instant`/`Batch`, or a host driving `dev_newBlock` under `Manual`), so
95
+ * it works identically across build modes.
96
+ *
97
+ * - **deliver**: push `receive_messages_proof` for `[last_delivered+1 .. latest_generated]` (chunked
98
+ * to MAX_MESSAGES_PER_PROOF) to the destination pool. Starting at the live `last_delivered+1` gives
99
+ * `is_obsolete` its contiguity and re-delivers nothing.
100
+ * - **confirm**: push `receive_messages_delivery_proof` to the source pool so `latest_received_nonce`
101
+ * advances and the destination prunes its relayers queue (else long runs hit the unconfirmed limit).
102
+ *
103
+ * `signer` must hold a balance on both sides.
104
+ */ export const connectBridgeHubs = async (source, destination, config)=>{
105
+ const sourceMessagesPallet = config.sourceMessagesPallet ?? detectSourceMessagesPallet(source);
106
+ const destParachainsPallet = config.destParachainsPallet ?? detectBridgeParachainsPallet(destination, 'destination');
107
+ const destMessagesPallet = config.destMessagesPallet ?? detectDestMessagesPallet(destination);
108
+ const dispatchWeight = config.dispatchWeightPerMessage ?? DEFAULT_DISPATCH_WEIGHT;
109
+ const signerAddress = typeof config.signer === 'string' ? config.signer : config.signer.address;
110
+ const relayerIdAtSource = config.relayerIdAtSource ?? signerAddress;
111
+ const [sourceParaId, destParaId] = await Promise.all([
112
+ config.sourceParaId ?? detectParaId(source, 'source'),
113
+ config.destParaId ?? detectParaId(destination, 'destination')
114
+ ]);
115
+ // Confirm proves the destination's inbound lane on the source, so it needs the source-side
116
+ // bridge-parachains pallet and the destination's para id (deliver's deps, reversed).
117
+ const sourceParachainsPallet = config.sourceParachainsPallet ?? detectBridgeParachainsPallet(source, 'source');
118
+ // camelCase pallet names for `api.query`/`api.tx` lookups.
119
+ const sourceMessages = stringCamelCase(sourceMessagesPallet);
120
+ const destMessages = stringCamelCase(destMessagesPallet);
121
+ const sourceParachains = stringCamelCase(sourceParachainsPallet);
122
+ const destParachains = stringCamelCase(destParachainsPallet);
123
+ // lane -> source `latest_generated_nonce` observed on source heads (deliver up to this).
124
+ const generated = new Map();
125
+ // lane -> `noncesEnd` of a delivery pushed to the destination pool but not yet observed applied.
126
+ const inFlight = new Map();
127
+ // lane -> highest destination `last_delivered_nonce` already confirmed back to source.
128
+ const confirmed = new Map();
129
+ // lane -> highest destination `last_delivered_nonce` observed by the deliver loop. It's
130
+ // monotonic, so it lets idle heads skip the per-lane inbound query.
131
+ const deliveredCache = new Map();
132
+ // Latest observed source head; outbound messages are proven against it (they exist there, since
133
+ // `latest_generated >= to`). Set on connect and refreshed on every source head.
134
+ let latestSourceHash;
135
+ let latestSourceNumber = 0;
136
+ // Push one chunk `[from..=to]` to the destination pool, proven against the given source head
137
+ // (the messages exist there since `latest_generated >= to`). Outbound lane state proven alongside.
138
+ const deliverRange = async (laneHex, from, to, sourceHash, sourceNumber)=>{
139
+ // `OutboundMessages` is keyed by `MessageKey { lane_id, nonce }`, passed as one struct arg.
140
+ const outboundMessages = source.query[sourceMessages].outboundMessages;
141
+ const keys = [
142
+ source.query[sourceMessages].outboundLanes.key(laneHex)
143
+ ];
144
+ for(let n = from; n <= to; n++){
145
+ keys.push(outboundMessages.key({
146
+ laneId: laneHex,
147
+ nonce: n
148
+ }));
149
+ }
150
+ // `dev_getReadProof` (not spec `state_getReadProof`) returns the recomputed `stateRoot` that the
151
+ // verifier checks against — it diverges from `header.state_root` once local overrides are applied.
152
+ const proof = await rawRpc(source, 'dev_getReadProof', [
153
+ keys,
154
+ sourceHash
155
+ ]);
156
+ const setStoragePayload = importParaHead(destination, destParachains, sourceParaId, sourceHash, sourceNumber, hexToU8a(proof.stateRoot));
157
+ await rawRpc(destination, 'dev_setStorage', [
158
+ setStoragePayload
159
+ ]);
160
+ const count = Number(to - from + 1n);
161
+ const tx = destination.tx[destMessages].receiveMessagesProof(relayerIdAtSource, {
162
+ bridgedHeaderHash: sourceHash,
163
+ storageProof: proof.proof,
164
+ lane: laneHex,
165
+ noncesStart: from,
166
+ noncesEnd: to
167
+ }, count, {
168
+ refTime: dispatchWeight.refTime * BigInt(count),
169
+ proofSize: dispatchWeight.proofSize * BigInt(count)
170
+ });
171
+ await submitTx(destination, tx, config.signer);
172
+ bridgeLogger.info({
173
+ lane: laneHex,
174
+ range: `${from}..=${to}`,
175
+ sourceBlock: sourceNumber
176
+ }, 'pushed bridge delivery');
177
+ };
178
+ // Push the next chunk for a lane if ready. Reads the live `last_delivered_nonce` so the proof
179
+ // starts at `last_delivered+1` (is_obsolete contiguity); an in-flight chunk blocks further pushes
180
+ // until a destination head shows it applied.
181
+ const tryDeliverLane = async (laneHex)=>{
182
+ const sourceHash = latestSourceHash;
183
+ const sourceNumber = latestSourceNumber;
184
+ if (sourceHash === undefined) return;
185
+ const generatedNonce = generated.get(laneHex) ?? 0n;
186
+ // Idle short-circuit: `last_delivered_nonce` is monotonic, so when the cached value already
187
+ // covers everything generated and nothing is in flight, skip the inbound query entirely.
188
+ if (generatedNonce <= (deliveredCache.get(laneHex) ?? 0n) && !inFlight.has(laneHex)) return;
189
+ const delivered = lastDeliveredFromInbound((await destination.query[destMessages].inboundLanes(laneHex)).toJSON());
190
+ deliveredCache.set(laneHex, delivered);
191
+ const pending = inFlight.get(laneHex);
192
+ if (pending !== undefined) {
193
+ if (delivered >= pending) inFlight.delete(laneHex);
194
+ else return; // previous chunk not applied yet — a further push would gap
195
+ }
196
+ if (generatedNonce <= delivered) return; // nothing new to deliver
197
+ const from = delivered + 1n;
198
+ const lastInChunk = from + MAX_MESSAGES_PER_PROOF - 1n;
199
+ const to = lastInChunk < generatedNonce ? lastInChunk : generatedNonce;
200
+ await deliverRange(laneHex, from, to, sourceHash, sourceNumber);
201
+ inFlight.set(laneHex, to);
202
+ };
203
+ // On any head, push whatever is currently deliverable on each known lane.
204
+ const syncDeliveries = async ()=>{
205
+ for (const laneHex of generated.keys()){
206
+ try {
207
+ await tryDeliverLane(laneHex);
208
+ } catch (err) {
209
+ const message = err.message;
210
+ inFlight.delete(laneHex); // allow a re-push on the next head
211
+ // a `stale` push means our `last_delivered` view lagged the chain — benign, just retry
212
+ if (isStaleError(message)) {
213
+ bridgeLogger.debug({
214
+ lane: laneHex
215
+ }, 'delivery push stale; will retry');
216
+ } else {
217
+ bridgeLogger.warn({
218
+ err: message,
219
+ lane: laneHex
220
+ }, 'delivery push failed; will retry');
221
+ }
222
+ }
223
+ }
224
+ };
225
+ // Relay one lane's confirmation to `source`: prove the destination's `InboundLanes[lane]` (at
226
+ // `destHashHex`, where the delivery is applied) and submit `receive_messages_delivery_proof`,
227
+ // advancing the source's `latest_received_nonce`. Mirror of deliver.
228
+ const confirm = async (destHashHex, destBlockNumber, lane)=>{
229
+ const { laneHex, relayers, lastDelivered } = lane;
230
+ // Idempotency guard: skip if the source already covers `lastDelivered` (else `is_obsolete`
231
+ // rejects the re-submit as stale).
232
+ const sourceReceived = BigInt((await source.query[sourceMessages].outboundLanes(laneHex)).toJSON()?.latestReceivedNonce ?? 0);
233
+ if (lastDelivered <= sourceReceived) return;
234
+ const relayersState = deriveUnrewardedRelayersState(relayers, lastDelivered);
235
+ const proof = await rawRpc(destination, 'dev_getReadProof', [
236
+ [
237
+ destination.query[destMessages].inboundLanes.key(laneHex)
238
+ ],
239
+ destHashHex
240
+ ]);
241
+ const setStoragePayload = importParaHead(source, sourceParachains, destParaId, destHashHex, destBlockNumber, hexToU8a(proof.stateRoot));
242
+ await rawRpc(source, 'dev_setStorage', [
243
+ setStoragePayload
244
+ ]);
245
+ const tx = source.tx[sourceMessages].receiveMessagesDeliveryProof({
246
+ bridgedHeaderHash: destHashHex,
247
+ storageProof: proof.proof,
248
+ lane: laneHex
249
+ }, relayersState);
250
+ await submitTx(source, tx, config.signer);
251
+ bridgeLogger.info({
252
+ lane: laneHex,
253
+ lastDelivered: lastDelivered.toString(),
254
+ destBlock: destBlockNumber
255
+ }, 'confirmed bridge deliveries');
256
+ };
257
+ // Per-lane `latest_generated_nonce` from `OutboundLanes` at a pinned source block.
258
+ const readOutboundLanes = async (apiAt)=>{
259
+ const entries = await apiAt.query[sourceMessages].outboundLanes.entries();
260
+ return entries.map(([key, valueCodec])=>{
261
+ const v = valueCodec.toJSON();
262
+ return [
263
+ laneHexFromKey(key),
264
+ BigInt(v?.latestGeneratedNonce ?? 0)
265
+ ];
266
+ });
267
+ };
268
+ // Per-lane `InboundLaneData` from `InboundLanes` at a pinned destination block, with
269
+ // `last_delivered_nonce` derived as the pallet does (see `lastDeliveredFromInbound`).
270
+ const readInboundLanes = async (apiAt)=>{
271
+ const entries = await apiAt.query[destMessages].inboundLanes.entries();
272
+ return entries.map(([key, valueCodec])=>{
273
+ const v = valueCodec.toJSON();
274
+ return {
275
+ laneHex: laneHexFromKey(key),
276
+ relayers: v?.relayers ?? [],
277
+ lastDelivered: lastDeliveredFromInbound(v)
278
+ };
279
+ });
280
+ };
281
+ // On a source head: pin it as the proving head and update each lane's generated high-water mark.
282
+ // Read at the header's own hash so a later proof attests exactly the nonces extant at that head.
283
+ const refreshGenerated = async (sourceHeader)=>{
284
+ const sourceHashHex = sourceHeader.hash.toHex();
285
+ try {
286
+ const lanes = await readOutboundLanes(await source.at(sourceHashHex));
287
+ latestSourceHash = sourceHashHex;
288
+ latestSourceNumber = sourceHeader.number.toNumber();
289
+ for (const [laneHex, latestGenerated] of lanes)generated.set(laneHex, latestGenerated);
290
+ } catch (err) {
291
+ bridgeLogger.warn({
292
+ err: err.message,
293
+ sourceHash: sourceHashHex
294
+ }, 'outboundLanes enumeration failed');
295
+ }
296
+ };
297
+ const pumpConfirm = async (destHeader)=>{
298
+ const destHashHex = destHeader.hash.toHex();
299
+ const destBlockNumber = destHeader.number.toNumber();
300
+ let lanes;
301
+ try {
302
+ lanes = await readInboundLanes(await destination.at(destHashHex));
303
+ } catch (err) {
304
+ bridgeLogger.warn({
305
+ err: err.message,
306
+ destHash: destHashHex
307
+ }, 'inboundLanes enumeration failed');
308
+ return;
309
+ }
310
+ for (const lane of lanes){
311
+ if (lane.lastDelivered <= (confirmed.get(lane.laneHex) ?? 0n)) continue;
312
+ try {
313
+ await confirm(destHashHex, destBlockNumber, lane);
314
+ confirmed.set(lane.laneHex, lane.lastDelivered);
315
+ } catch (err) {
316
+ const message = err.message;
317
+ // A `stale` rejection is benign: the source lane already advanced (a competing
318
+ // confirmation, or our own already-applied submit racing the read), so mark it done.
319
+ if (isStaleError(message)) {
320
+ confirmed.set(lane.laneHex, lane.lastDelivered);
321
+ bridgeLogger.debug({
322
+ lane: lane.laneHex
323
+ }, 'confirmation already applied (stale); skipping');
324
+ } else {
325
+ // Leave `confirmed` un-advanced so the next destination head retries.
326
+ bridgeLogger.warn({
327
+ err: message,
328
+ lane: lane.laneHex
329
+ }, 'confirmation failed; will retry');
330
+ }
331
+ }
332
+ }
333
+ };
334
+ // Delivery self-seeds (live `last_delivered` + `refreshGenerated` on the first source head), so
335
+ // only confirm needs a baseline: pin `confirmed` to the current `last_delivered` so a fork's
336
+ // pre-existing delivered-but-unconfirmed backlog isn't re-confirmed.
337
+ try {
338
+ const destHead = await destination.rpc.chain.getBlockHash();
339
+ for (const lane of (await readInboundLanes(await destination.at(destHead)))){
340
+ confirmed.set(lane.laneHex, lane.lastDelivered);
341
+ }
342
+ } catch (err) {
343
+ bridgeLogger.warn({
344
+ err: err.message
345
+ }, 'confirmation baseline seed failed; lanes baseline on first head');
346
+ }
347
+ // Delivery is serialized across both head streams (source heads update generated, destination
348
+ // heads observe applied chunks); confirmation is its own queue on destination heads.
349
+ const deliverQueue = serialChain('unexpected deliver error');
350
+ const confirmQueue = serialChain('unexpected confirm-pump error');
351
+ const unsubSource = await source.rpc.chain.subscribeNewHeads((header)=>{
352
+ deliverQueue.enqueue(async ()=>{
353
+ await refreshGenerated(header);
354
+ await syncDeliveries();
355
+ });
356
+ });
357
+ const unsubDest = await destination.rpc.chain.subscribeNewHeads((header)=>{
358
+ // A destination block may have applied an in-flight chunk → push the next one.
359
+ deliverQueue.enqueue(()=>syncDeliveries());
360
+ confirmQueue.enqueue(()=>pumpConfirm(header));
361
+ });
362
+ bridgeLogger.info({
363
+ sourceMessagesPallet,
364
+ destParachainsPallet,
365
+ destMessagesPallet,
366
+ sourceParaId,
367
+ sourceParachainsPallet,
368
+ destParaId
369
+ }, 'bridge connector started');
370
+ return {
371
+ async disconnect () {
372
+ unsubSource();
373
+ unsubDest();
374
+ await Promise.all([
375
+ deliverQueue.drain(),
376
+ confirmQueue.drain()
377
+ ]);
378
+ bridgeLogger.info('bridge connector stopped');
379
+ }
380
+ };
381
+ };
382
+ const rawRpc = async (api, method, params)=>{
383
+ const provider = api._rpcCore?.provider;
384
+ if (!provider?.send) throw new Error(`connectBridgeHubs: cannot access provider for ${method}`);
385
+ return provider.send(method, params);
386
+ };
387
+ /** Storage-shape predicate for a `pallet_bridge_messages` instance. */ const isBridgeMessagesPallet = (pallet)=>!!(pallet?.outboundLanes && pallet?.outboundMessages);
388
+ /** Whether `api`'s runtime registers a `pallet_bridge_messages` instance. */ export const hasBridgeMessagesPallet = (api)=>Object.values(api.query).some(isBridgeMessagesPallet);
389
+ /**
390
+ * Find the unique pallet in `namespace` matching `predicate`, returning its PascalCase
391
+ * name. Throws a remediation-pointing error on zero or multiple matches.
392
+ */ const detectPallet = (namespace, predicate, kind, label, overrideField)=>{
393
+ const matches = Object.keys(namespace).filter((key)=>predicate(namespace[key]));
394
+ if (matches.length === 0) throw new Error(`connectBridgeHubs: no pallet_bridge_${kind} instance on ${label}`);
395
+ if (matches.length > 1) {
396
+ const names = matches.map(stringPascalCase).join(', ');
397
+ throw new Error(`connectBridgeHubs: ${label} has multiple bridge-${kind} instances (${names}); set ${overrideField}`);
398
+ }
399
+ return stringPascalCase(matches[0]);
400
+ };
401
+ const detectSourceMessagesPallet = (api)=>detectPallet(api.query, isBridgeMessagesPallet, 'messages', 'source', 'sourceMessagesPallet');
402
+ const detectDestMessagesPallet = (api)=>detectPallet(api.tx, (p)=>p?.receiveMessagesProof, 'messages', 'destination', 'destMessagesPallet');
403
+ const detectBridgeParachainsPallet = (api, label)=>detectPallet(api.query, (p)=>p?.parasInfo && p?.importedParaHeads, 'parachains', label, label === 'source' ? 'sourceParachainsPallet' : 'destParachainsPallet');
404
+ const detectParaId = async (api, label)=>{
405
+ const idCodec = await api.query.parachainInfo?.parachainId?.();
406
+ if (idCodec) return idCodec.toNumber();
407
+ throw new Error(`connectBridgeHubs: cannot auto-detect ${label === 'source' ? 'sourceParaId' : 'destParaId'}`);
408
+ };
package/dist/esm/cli.js CHANGED
@@ -1,13 +1,61 @@
1
1
  import { connectParachains, connectVertical, environment } from '@acala-network/chopsticks-core';
2
+ import { ApiPromise, Keyring, WsProvider } from '@polkadot/api';
3
+ import { cryptoWaitReady } from '@polkadot/util-crypto';
2
4
  import { config as dotenvConfig } from 'dotenv';
3
5
  import _ from 'lodash';
4
6
  import yargs from 'yargs';
5
7
  import { hideBin } from 'yargs/helpers';
6
8
  import { z } from 'zod';
9
+ import { connectBridgeHubs, hasBridgeMessagesPallet } from './bridge.js';
7
10
  import { setupWithServer } from './index.js';
8
11
  import { loadRpcMethodsByScripts, pluginExtendCli } from './plugins/index.js';
9
12
  import { configSchema, fetchConfig, getYargsOptions } from './schema/index.js';
10
13
  dotenvConfig();
14
+ /** Fork relay (optional) + parachains and wire them with UMP/DMP/HRMP — the `xcm` command's core. */ const setupNetwork = async (relayConfigRef, parachainConfigRefs)=>{
15
+ const parachains = [];
16
+ for (const configRef of parachainConfigRefs){
17
+ const { chain, addr } = await setupWithServer(await fetchConfig(configRef));
18
+ parachains.push({
19
+ chain,
20
+ addr,
21
+ configRef
22
+ });
23
+ }
24
+ if (parachains.length > 1) {
25
+ await connectParachains(parachains.map((c)=>c.chain), environment.DISABLE_AUTO_HRMP);
26
+ }
27
+ if (relayConfigRef) {
28
+ const { chain: relay } = await setupWithServer(await fetchConfig(relayConfigRef));
29
+ for (const c of parachains)await connectVertical(relay, c.chain);
30
+ }
31
+ return parachains;
32
+ };
33
+ /** One side of a bridged setup: an xcm network plus an `ApiPromise` per parachain. */ const setupBridgeSide = async (relayConfigRef, parachainConfigRefs)=>{
34
+ const parachains = await setupNetwork(relayConfigRef, parachainConfigRefs);
35
+ return Promise.all(parachains.map(async ({ chain, addr, configRef })=>{
36
+ const url = `ws://${addr}`;
37
+ const api = await ApiPromise.create({
38
+ provider: new WsProvider(url, 3_000),
39
+ noInitWarn: true
40
+ });
41
+ return {
42
+ chain,
43
+ api,
44
+ url,
45
+ configRef
46
+ };
47
+ }));
48
+ };
49
+ /** Pick the parachain whose runtime registers `pallet_bridge_messages`. */ const findBridgeHub = (side, label)=>{
50
+ const candidates = side.filter((c)=>hasBridgeMessagesPallet(c.api));
51
+ if (candidates.length === 0) {
52
+ throw new Error(`chopsticks bridge: no ${label}-side parachain hosts pallet_bridge_messages. ` + `Provided: ${side.map((c)=>c.configRef).join(', ')}. Include a bridge-hub config.`);
53
+ }
54
+ if (candidates.length > 1) {
55
+ throw new Error(`chopsticks bridge: multiple ${label}-side parachains host pallet_bridge_messages (${candidates.map((c)=>c.configRef).join(', ')}). Cannot disambiguate — provide only one bridge hub per side.`);
56
+ }
57
+ return candidates[0];
58
+ };
11
59
  const processArgv = async (argv)=>{
12
60
  try {
13
61
  if (argv.unsafeRpcMethods) {
@@ -42,20 +90,61 @@ const commands = yargs(hideBin(process.argv)).scriptName('chopsticks').middlewar
42
90
  required: true
43
91
  }
44
92
  }).alias('relaychain', 'r').alias('parachain', 'p'), async (argv)=>{
45
- const parachains = [];
46
- for (const config of argv.parachain){
47
- const { chain } = await setupWithServer(await fetchConfig(config));
48
- parachains.push(chain);
49
- }
50
- if (parachains.length > 1) {
51
- await connectParachains(parachains, environment.DISABLE_AUTO_HRMP);
52
- }
53
- if (argv.relaychain) {
54
- const { chain: relaychain } = await setupWithServer(await fetchConfig(argv.relaychain));
55
- for (const parachain of parachains){
56
- await connectVertical(relaychain, parachain);
93
+ await setupNetwork(argv.relaychain, argv.parachain);
94
+ }).command('bridge', 'Bridged-XCM setup: two ecosystems wired with pallet_bridge_messages in both directions', (yargs)=>yargs.options({
95
+ 'left-relaychain': {
96
+ desc: 'Left-side relaychain config (named or path). Example: westend',
97
+ string: true
98
+ },
99
+ 'left-parachain': {
100
+ desc: 'Left-side parachain config(s). One of them must host pallet_bridge_messages.',
101
+ type: 'array',
102
+ string: true,
103
+ required: true
104
+ },
105
+ 'right-relaychain': {
106
+ desc: 'Right-side relaychain config (named or path). Example: rococo',
107
+ string: true
108
+ },
109
+ 'right-parachain': {
110
+ desc: 'Right-side parachain config(s). One of them must host pallet_bridge_messages.',
111
+ type: 'array',
112
+ string: true,
113
+ required: true
114
+ },
115
+ 'bridge-signer-uri': {
116
+ desc: 'Relayer URI for the forward direction (left → right). Submits receive_messages_proof / _delivery_proof.',
117
+ string: true,
118
+ default: '//Alice'
119
+ },
120
+ 'bridge-reverse-signer-uri': {
121
+ desc: 'Relayer URI for the reverse direction (right → left). Defaults to <bridge-signer-uri>//reverse. Must differ from the forward relayer so their nonces on the shared hubs do not collide.',
122
+ string: true
57
123
  }
58
- }
124
+ }).alias('left-relaychain', 'r').alias('left-parachain', 'p').alias('right-relaychain', 'R').alias('right-parachain', 'P'), async (argv)=>{
125
+ await cryptoWaitReady();
126
+ const left = await setupBridgeSide(argv['left-relaychain'], argv['left-parachain']);
127
+ const right = await setupBridgeSide(argv['right-relaychain'], argv['right-parachain']);
128
+ const leftBh = findBridgeHub(left, 'left');
129
+ const rightBh = findBridgeHub(right, 'right');
130
+ // Each direction needs its OWN relayer account. Both connectors submit relayer txs to the
131
+ // same two hubs (the forward direction's confirmations and the reverse direction's deliveries
132
+ // both land on a given hub), so a shared signer collides on its nonce and one direction's tx
133
+ // is silently dropped. Distinct accounts give each direction its own nonce sequence.
134
+ const keyring = new Keyring({
135
+ type: 'sr25519'
136
+ });
137
+ const forwardSigner = keyring.addFromUri(argv['bridge-signer-uri']);
138
+ const reverseSigner = keyring.addFromUri(argv['bridge-reverse-signer-uri'] ?? `${argv['bridge-signer-uri']}//reverse`);
139
+ await Promise.all([
140
+ connectBridgeHubs(leftBh.api, rightBh.api, {
141
+ signer: forwardSigner
142
+ }),
143
+ connectBridgeHubs(rightBh.api, leftBh.api, {
144
+ signer: reverseSigner
145
+ })
146
+ ]);
147
+ console.log(`Bridge connected:\n left bridge-hub @ ${leftBh.url}\n right bridge-hub @ ${rightBh.url}\n` + `Relayers (fund BOTH on BOTH hubs): forward ${forwardSigner.address}, reverse ${reverseSigner.address}. ` + 'They push proofs to each hub; blocks are produced by the hubs (build mode) or by you (dev_newBlock).');
59
148
  }).strict().help().alias('help', 'h').alias('version', 'v').alias('config', 'c').alias('endpoint', 'e').alias('port', 'p').alias('block', 'b').alias('unsafe-rpc-methods', 'ur').alias('import-storage', 's').alias('wasm-override', 'w').usage('Usage: $0 <command> [options]').example('$0', '-c acala').showHelpOnFail(false);
60
149
  if (!environment.DISABLE_PLUGINS) {
61
150
  pluginExtendCli(commands.config('config', 'Path to config file with default options', ()=>({}))).then(()=>commands.parse());
@@ -1,4 +1,5 @@
1
1
  import '@polkadot/api-augment';
2
2
  export * from '@acala-network/chopsticks-core';
3
+ export * from './bridge.js';
3
4
  export { fetchConfig } from './schema/index.js';
4
5
  export { setupWithServer } from './setup-with-server.js';
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import '@polkadot/api-augment';
2
2
  export * from '@acala-network/chopsticks-core';
3
+ export * from './bridge.js';
3
4
  export { fetchConfig } from './schema/index.js';
4
5
  export { setupWithServer } from './setup-with-server.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acala-network/chopsticks",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "author": "Acala Developers <hello@acala.network>",
5
5
  "license": "Apache-2.0",
6
6
  "bin": "./chopsticks.cjs",
@@ -15,8 +15,8 @@
15
15
  "depcheck": "npx depcheck --ignore-patterns='*.test.ts'"
16
16
  },
17
17
  "dependencies": {
18
- "@acala-network/chopsticks-core": "1.4.2",
19
- "@acala-network/chopsticks-db": "1.4.2",
18
+ "@acala-network/chopsticks-core": "1.5.0",
19
+ "@acala-network/chopsticks-db": "1.5.0",
20
20
  "@dmsnell/diff-match-patch": "^1.1.0",
21
21
  "@pnpm/npm-conf": "^3.0.0",
22
22
  "@polkadot/api": "^16.4.1",
@@ -29,10 +29,10 @@
29
29
  "comlink": "^4.4.2",
30
30
  "dotenv": "^16.6.1",
31
31
  "global-agent": "^3.0.0",
32
- "js-yaml": "^4.1.1",
32
+ "js-yaml": "^4.2.0",
33
33
  "jsondiffpatch": "^0.7.3",
34
34
  "lodash": "^4.18.1",
35
- "ws": "^8.20.1",
35
+ "ws": "^8.21.0",
36
36
  "yargs": "^18.0.0",
37
37
  "zod": "^3.25.76"
38
38
  },