@totemsdk/omnia-host 0.8.0 → 0.10.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.
@@ -27,8 +27,12 @@ async function dispatch(request, methods) {
27
27
  return response(request.id, await method(request.params));
28
28
  }
29
29
  catch (error) {
30
- console.error('[jsonrpc] handler error:', error instanceof Error ? error.stack ?? error.message : String(error));
31
- return errorResponse(request.id, -32000, 'Internal error');
30
+ // Surface the handler's message to the caller. Masking it as "Internal
31
+ // error" made operational failures (broadcast rejection, missing manager,
32
+ // malformed params) impossible to diagnose from the client side.
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ console.error('[jsonrpc] handler error:', error instanceof Error ? error.stack ?? message : message);
35
+ return errorResponse(request.id, -32000, message);
32
36
  }
33
37
  }
34
38
  async function readBody(req) {
@@ -17,6 +17,14 @@ export interface HostApiContext {
17
17
  chainProvider?: ChainStateProvider;
18
18
  swarm?: OmniaSwarm;
19
19
  localParticipant?: ChannelParticipant;
20
+ /** Native transport identity, published in the signed manifest. */
21
+ transportPubkey?: string;
22
+ /**
23
+ * Signs the funding-transaction digest with the funding coin's key, so
24
+ * openChannel can assemble the funding witness without the caller reproducing
25
+ * the funding draft. Configured from the node's `sign` command.
26
+ */
27
+ fundingSigner?: (digest: Uint8Array) => Promise<Uint8Array> | Uint8Array;
20
28
  factories?: Map<string, ChannelFactory>;
21
29
  factoryBundles?: Record<string, WotsLeaseBundle>;
22
30
  spliceProposals?: Map<string, SpliceProposal>;
@@ -39,4 +47,36 @@ export interface HostApiContext {
39
47
  * read everything they need without reaching into `getStore()`/`getMethods()`.
40
48
  */
41
49
  export declare function channelStateView(channel: OmniaChannel): Record<string, unknown>;
50
+ /**
51
+ * Wait for the counterparty to acknowledge a channel proposal. A channel is not
52
+ * usable on both sides until the peer has accepted it; without this, an
53
+ * immediate `pay()` can race ahead of the peer's accept and its STATE_UPDATE is
54
+ * dropped ("Unknown channel"). Resolves true on ACK, false on ERROR/timeout.
55
+ */
56
+ interface PeerReply {
57
+ type: string;
58
+ channelId?: string;
59
+ nonce?: number;
60
+ payload?: unknown;
61
+ }
62
+ interface ReplyPeer {
63
+ sendMessage(msg: PeerReply & {
64
+ channelId: string;
65
+ }): Promise<void>;
66
+ onMessage(cb: (msg: PeerReply) => void): () => void;
67
+ onReconnected?(cb: () => void): () => void;
68
+ }
69
+ /**
70
+ * Send a proposal and wait for its ACK/ERROR reply.
71
+ *
72
+ * The message is resent with the SAME nonce when the peer reconnects (a
73
+ * proposal or its reply may have been lost when the old stream closed) and
74
+ * once mid-flight in case the reply was lost without the stream closing.
75
+ * Receivers are expected to deduplicate by `(type, channelId, nonce)` so a
76
+ * resend never consumes another WOTS leaf or yields a different signature.
77
+ */
78
+ export declare function requestPeerReply(peer: ReplyPeer, msg: PeerReply & {
79
+ channelId: string;
80
+ }, channelId: string, timeoutMs: number, types?: string[]): Promise<PeerReply | null>;
42
81
  export declare function createHostMethods(context: HostApiContext): Map<string, JsonRpcHandler>;
82
+ export {};
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.channelStateView = channelStateView;
4
+ exports.requestPeerReply = requestPeerReply;
4
5
  exports.createHostMethods = createHostMethods;
5
6
  const omnia_1 = require("@totemsdk/omnia");
6
7
  const omnia_router_1 = require("@totemsdk/omnia-router");
@@ -48,8 +49,10 @@ function channelStateView(channel) {
48
49
  totalValue: channel.totalValue,
49
50
  currentSequence: channel.currentSequence,
50
51
  fundingAddress: channel.fundingAddress,
52
+ fundingScript: channel.fundingScript,
51
53
  fundingTxId: channel.fundingTxId,
52
54
  fundingCoinId: channel.fundingCoinId,
55
+ contestDelayBlocks: channel.contestDelayBlocks,
53
56
  parties: channel.parties,
54
57
  balances: channel.balances,
55
58
  pendingHTLCs: channel.pendingHTLCs,
@@ -174,7 +177,7 @@ async function persistProgramUpdate(context, channelId, run) {
174
177
  context.channels.set(channelId, result.channel);
175
178
  const remoteParty = channel.parties.find((party) => party.partyId !== context.localParticipant.partyId);
176
179
  if (context.swarm && remoteParty) {
177
- const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId);
180
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey);
178
181
  await peer.sendMessage({ type: 'STATE_UPDATE', channelId, nonce: Date.now(), payload: result.signedState });
179
182
  }
180
183
  return {
@@ -184,6 +187,57 @@ async function persistProgramUpdate(context, channelId, run) {
184
187
  balances: balancesJson(result.channel),
185
188
  };
186
189
  }
190
+ /**
191
+ * Send a proposal and wait for its ACK/ERROR reply.
192
+ *
193
+ * The message is resent with the SAME nonce when the peer reconnects (a
194
+ * proposal or its reply may have been lost when the old stream closed) and
195
+ * once mid-flight in case the reply was lost without the stream closing.
196
+ * Receivers are expected to deduplicate by `(type, channelId, nonce)` so a
197
+ * resend never consumes another WOTS leaf or yields a different signature.
198
+ */
199
+ function requestPeerReply(peer, msg, channelId, timeoutMs, types = ['ACK', 'ERROR']) {
200
+ return new Promise((resolve) => {
201
+ let done = false;
202
+ let timer;
203
+ let fallback;
204
+ const unsubs = [];
205
+ const finish = (reply) => {
206
+ if (done)
207
+ return;
208
+ done = true;
209
+ if (timer)
210
+ clearTimeout(timer);
211
+ if (fallback)
212
+ clearTimeout(fallback);
213
+ for (const unsub of unsubs)
214
+ unsub();
215
+ resolve(reply);
216
+ };
217
+ const resend = (reason) => {
218
+ if (done)
219
+ return;
220
+ if (process.env.OMNIA_DEBUG) {
221
+ console.error('[peer] resend', msg.type, msg.nonce, reason, channelId);
222
+ }
223
+ void peer.sendMessage(msg).catch(() => { });
224
+ };
225
+ unsubs.push(peer.onMessage((reply) => {
226
+ if (reply.channelId !== channelId)
227
+ return;
228
+ if (msg.nonce !== undefined && reply.nonce !== undefined && reply.nonce !== msg.nonce)
229
+ return;
230
+ if (types.includes(reply.type))
231
+ finish(reply);
232
+ }));
233
+ if (peer.onReconnected) {
234
+ unsubs.push(peer.onReconnected(() => resend('reconnected')));
235
+ }
236
+ fallback = setTimeout(() => resend('no reply'), Math.min(10_000, Math.floor(timeoutMs / 2)));
237
+ timer = setTimeout(() => finish(null), timeoutMs);
238
+ void peer.sendMessage(msg).catch(() => { });
239
+ });
240
+ }
187
241
  function createHostMethods(context) {
188
242
  const methods = new Map();
189
243
  const register = (name, handler, alias) => {
@@ -276,27 +330,70 @@ function createHostMethods(context) {
276
330
  publicKeyDigest: remotePublicKeyDigest,
277
331
  addressIndex: typeof params.remoteAddressIndex === 'number' ? params.remoteAddressIndex : 0,
278
332
  settlementAddress: typeof params.remoteSettlementAddress === 'string' ? params.remoteSettlementAddress : undefined,
333
+ transportPubkey: typeof params.remoteTransportPubkey === 'string' ? params.remoteTransportPubkey : undefined,
279
334
  };
280
335
  const result = await withOperation(context, params, async () => {
281
336
  const createParams = {
282
- localParty: context.localParticipant,
337
+ localParty: {
338
+ ...context.localParticipant,
339
+ transportPubkey: context.localParticipant.transportPubkey ?? context.transportPubkey,
340
+ },
283
341
  remoteParty: remote,
284
342
  localAmount: requiredBigInt(params, 'localAmount'),
285
343
  remoteAmount: requiredBigInt(params, 'remoteAmount'),
286
344
  tokenId: typeof params.tokenId === 'string' ? params.tokenId : undefined,
287
345
  fundingCoinId: requiredString(params, 'fundingCoinId'),
288
- fundingWitnessBytes: Buffer.from(requiredString(params, 'fundingWitnessHex').replace(/^0x/i, ''), 'hex'),
346
+ contestDelayBlocks: typeof params.contestDelayBlocks === 'number' ? params.contestDelayBlocks : undefined,
289
347
  };
348
+ const fundingWitnessHex = typeof params.fundingWitnessHex === 'string' ? params.fundingWitnessHex : undefined;
349
+ if (fundingWitnessHex) {
350
+ createParams.fundingWitnessBytes = Buffer.from(fundingWitnessHex.replace(/^0x/i, ''), 'hex');
351
+ }
352
+ else {
353
+ // Assemble the funding witness: createChannel builds the funding draft,
354
+ // a funding signer signs its digest, and the caller supplies the input
355
+ // proofs (serialized CoinProof + ScriptProof). The signer comes from the
356
+ // configured funding signer, or from the node's `sign` command using the
357
+ // `fundingPubkey` the caller passes.
358
+ const fundingPubkey = typeof params.fundingPubkey === 'string' ? params.fundingPubkey : undefined;
359
+ const nodeAdapter = context.chainProvider;
360
+ const signer = context.fundingSigner ?? (fundingPubkey && typeof nodeAdapter?.signData === 'function'
361
+ ? async (digest) => {
362
+ const sig = await nodeAdapter.signData(fundingPubkey, '0x' + Buffer.from(digest).toString('hex'));
363
+ return Uint8Array.from(Buffer.from(sig.replace(/^0x/i, ''), 'hex'));
364
+ }
365
+ : undefined);
366
+ if (!signer) {
367
+ throw new Error('openChannel requires fundingWitnessHex, or a fundingPubkey + funding proofs');
368
+ }
369
+ if (typeof params.fundingScript !== 'string' && !Array.isArray(params.fundingScriptProofsHex)) {
370
+ throw new Error('openChannel requires fundingScript (or serialized fundingScriptProofsHex) for the funding input address');
371
+ }
372
+ const toBytes = (v) => Array.isArray(v) ? v.map((h) => Buffer.from(String(h).replace(/^0x/i, ''), 'hex')) : [];
373
+ createParams.fundingProofs = {
374
+ coinProofs: toBytes(params.fundingCoinProofsHex),
375
+ scriptProofs: toBytes(params.fundingScriptProofsHex),
376
+ };
377
+ // The funding coin's address script: createChannel derives the required
378
+ // single-leaf ScriptProof from it, so callers need not hand-roll it.
379
+ if (typeof params.fundingScript === 'string') {
380
+ createParams.fundingScript = params.fundingScript;
381
+ }
382
+ createParams.fundingSigner = signer;
383
+ }
290
384
  const created = await (0, omnia_1.createChannel)(createParams, context.chainProvider);
291
385
  context.channels.set(created.channel.channelId, {
292
386
  ...created.channel,
293
387
  localSigner: context.signer,
294
388
  });
389
+ let acknowledged = true;
295
390
  if (context.swarm) {
296
- const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, created.channel.channelId);
297
- await peer.sendMessage({ type: 'CHANNEL_PROPOSAL', channelId: created.channel.channelId, nonce: Date.now(), payload: created.proposal });
391
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, created.channel.channelId, remote.transportPubkey);
392
+ // Give the peer time to accept so a follow-up pay() is not lost.
393
+ const reply = await requestPeerReply(peer, { type: 'CHANNEL_PROPOSAL', channelId: created.channel.channelId, nonce: Date.now(), payload: created.proposal }, created.channel.channelId, 20_000);
394
+ acknowledged = reply?.type === 'ACK';
298
395
  }
299
- return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId };
396
+ return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId, acknowledged };
300
397
  });
301
398
  return result;
302
399
  };
@@ -326,7 +423,8 @@ function createHostMethods(context) {
326
423
  if (result.error)
327
424
  throw new Error(result.error);
328
425
  context.channels.set(channelId, result.channel);
329
- const peer = context.swarm ? await context.swarm.connectToPeer(channel.parties.find((party) => party.partyId === remotePartyId).publicKeyDigest, channelId) : undefined;
426
+ const remoteParty = channel.parties.find((party) => party.partyId === remotePartyId);
427
+ const peer = context.swarm ? await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey) : undefined;
330
428
  if (peer)
331
429
  await peer.sendMessage({ type: 'STATE_UPDATE', channelId, nonce: Date.now(), payload: result.signedState });
332
430
  return { success: true, channelId, sequence: result.channel.currentSequence, localBalance: result.channel.balances[localPartyId].toString(), remoteBalance: result.channel.balances[remotePartyId].toString() };
@@ -387,11 +485,42 @@ function createHostMethods(context) {
387
485
  if (!channel)
388
486
  throw new Error(`Channel ${channelId} not found`);
389
487
  const closing = (0, omnia_1.markChannelClosing)(channel, 'mutual');
488
+ const partyAddresses = Object.fromEntries(closing.parties.map((party) => [party.partyId, party.settlementAddress ?? party.publicKeyDigest]));
489
+ // Cooperative close needs both eltoo MULTISIG(2 A B) signatures. Build the
490
+ // exact settlement draft, ask the counterparty to co-sign it, then include
491
+ // both signatures in the broadcast witness.
492
+ const localPartyId = context.localParticipant?.partyId;
493
+ const remoteParty = localPartyId
494
+ ? closing.parties.find((party) => party.partyId !== localPartyId)
495
+ : undefined;
496
+ let counterpartySignatures;
497
+ let settlementTxHex;
498
+ if (context.swarm && remoteParty) {
499
+ const request = (0, omnia_1.buildSettlementSigningRequest)(closing, { partyAddresses });
500
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey);
501
+ const reply = await requestPeerReply(peer, {
502
+ type: 'SETTLEMENT_PROPOSAL',
503
+ channelId,
504
+ nonce: Date.now(),
505
+ payload: { signRequest: true, ...request },
506
+ }, channelId, 30_000);
507
+ const replyPayload = reply?.payload;
508
+ if (reply?.type !== 'ACK' || typeof replyPayload?.signature !== 'string') {
509
+ throw new Error(`Counterparty did not co-sign the settlement (${reply?.type ?? 'timeout'})`);
510
+ }
511
+ counterpartySignatures = [{
512
+ partyId: replyPayload.partyId ?? remoteParty.partyId,
513
+ signature: Buffer.from(replyPayload.signature.replace(/^0x/i, ''), 'hex'),
514
+ }];
515
+ settlementTxHex = request.settlementTxHex;
516
+ }
390
517
  const settlement = await (0, omnia_1.proposeSettlement)(closing, context.leaseProvider, {
391
518
  signer: context.signer,
392
519
  chainProvider: context.chainProvider,
393
520
  broadcastProofs: parsedProofs,
394
- partyAddresses: Object.fromEntries(closing.parties.map((party) => [party.partyId, party.settlementAddress ?? party.publicKeyDigest])),
521
+ partyAddresses,
522
+ settlementTxHex,
523
+ counterpartySignatures,
395
524
  });
396
525
  context.channels.set(channelId, (0, omnia_1.markChannelClosed)(closing));
397
526
  return { success: true, channelId, settlementTxId: settlement.settlementPayload.txpowId, finalBalances: Object.fromEntries(Object.entries(settlement.settlementPayload.balances).map(([key, value]) => [key, value.toString()])) };
@@ -412,7 +541,7 @@ function createHostMethods(context) {
412
541
  register('totem_omniaCloseChannel', closeChannel, 'omnia/closeChannel');
413
542
  const createFactoryMethod = async (raw) => {
414
543
  const params = paramsObject(raw);
415
- if (!context.factories || !context.factoryBundles || !context.chainProvider)
544
+ if (!context.factories || !context.factoryBundles)
416
545
  throw new Error('Factory manager is not configured');
417
546
  const participants = params.participants;
418
547
  if (!Array.isArray(participants) || participants.length < 2)
@@ -428,17 +557,106 @@ function createHostMethods(context) {
428
557
  contributionAmount: BigInt(requiredString(item, 'contributionAmount')),
429
558
  fundingCoinId: typeof item.fundingCoinId === 'string' ? item.fundingCoinId : undefined,
430
559
  settlementAddress: typeof item.settlementAddress === 'string' ? item.settlementAddress : undefined,
560
+ transportPubkey: typeof item.transportPubkey === 'string' ? item.transportPubkey : undefined,
431
561
  };
432
562
  });
563
+ const localPartyId = context.localParticipant?.partyId;
564
+ if (!localPartyId)
565
+ throw new Error('Local participant is not configured');
566
+ const localBundle = context.factoryBundles[localPartyId];
567
+ if (!localBundle)
568
+ throw new Error('No local factory lease bundle');
569
+ const remote = parsed.find((participant) => participant.partyId !== localPartyId);
570
+ const localSigner = context.signer && context.leaseProvider
571
+ ? async (digest) => {
572
+ const reservation = await context.leaseProvider.reserveKeyUse({
573
+ treeId: 'factory-funding',
574
+ purpose: 'factory-funding',
575
+ payloadHash: '0x' + Buffer.from(digest).toString('hex'),
576
+ });
577
+ try {
578
+ const sig = await context.signer.sign(digest, reservation.indices);
579
+ await context.leaseProvider.commitKeyUse(reservation.reservationId, 'factory-funding');
580
+ return sig;
581
+ }
582
+ catch (err) {
583
+ await context.leaseProvider.burnReservation(reservation.reservationId, 'factory-funding-failed').catch(() => { });
584
+ throw err;
585
+ }
586
+ }
587
+ : undefined;
588
+ let fundingPeer;
589
+ const remoteSigner = context.swarm && remote
590
+ ? async (digest) => {
591
+ fundingPeer ??= await context.swarm.connectToPeer(remote.publicKeyDigest, 'factory-funding', remote.transportPubkey);
592
+ const channelId = 'factory-funding';
593
+ const reply = await requestPeerReply(fundingPeer, {
594
+ type: 'FACTORY_FUNDING_REQUEST',
595
+ channelId,
596
+ nonce: Date.now(),
597
+ payload: { digest: '0x' + Buffer.from(digest).toString('hex') },
598
+ }, channelId, 30_000, ['FACTORY_FUNDING_RESPONSE', 'ERROR']);
599
+ const signature = reply?.payload?.signature;
600
+ if (!signature) {
601
+ throw new Error(`Counterparty did not co-sign factory funding (${reply?.type ?? 'timeout'})`);
602
+ }
603
+ return Uint8Array.from(Buffer.from(signature.replace(/^0x/i, ''), 'hex'));
604
+ }
605
+ : undefined;
606
+ const funding = Array.isArray(params.funding)
607
+ ? params.funding.map((value) => {
608
+ if (!value || typeof value !== 'object')
609
+ throw new Error('Invalid factory funding material');
610
+ const item = value;
611
+ const hex = (key) => {
612
+ const raw = item[key];
613
+ if (typeof raw !== 'string')
614
+ return undefined;
615
+ return Buffer.from(raw.replace(/^0x/i, ''), 'hex');
616
+ };
617
+ const coinProof = hex('coinProof');
618
+ if (!coinProof)
619
+ throw new Error('factory funding material requires coinProof');
620
+ const partyId = requiredString(item, 'partyId');
621
+ const signature = hex('signature');
622
+ const signer = signature
623
+ ? undefined
624
+ : partyId === localPartyId
625
+ ? localSigner
626
+ : remoteSigner;
627
+ return {
628
+ partyId,
629
+ coinProof,
630
+ ...(typeof item.coinAddress === 'string' ? { coinAddress: item.coinAddress } : {}),
631
+ ...(typeof item.script === 'string' ? { script: item.script } : {}),
632
+ ...(hex('scriptProof') ? { scriptProof: hex('scriptProof') } : {}),
633
+ ...(signature ? { signature } : signer ? { signer } : {}),
634
+ };
635
+ })
636
+ : undefined;
433
637
  const tokenId = typeof params.tokenId === 'string' ? params.tokenId : '0x00';
434
- const bundles = context.factoryBundles;
435
- const proposer = parsed.find((participant) => bundles[participant.partyId]);
436
- if (!proposer)
437
- throw new Error('No local factory lease bundle matches a participant');
438
638
  return withOperation(context, params, async () => {
439
- const factory = await (0, omnia_factory_1.createFactory)(parsed, tokenId, bundles[proposer.partyId], context.chainProvider);
639
+ // Off-chain when no funding material is supplied; with funding material the
640
+ // SDK builds, signs, and submits the on-chain factory funding TX.
641
+ let factory = await (0, omnia_factory_1.createFactory)(parsed, tokenId, localBundle, context.chainProvider, 0, funding);
642
+ if (context.swarm && remote) {
643
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, factory.factoryId, remote.transportPubkey);
644
+ const reply = await requestPeerReply(peer, {
645
+ type: 'FACTORY_PROPOSAL',
646
+ channelId: factory.factoryId,
647
+ nonce: Date.now(),
648
+ payload: { factory },
649
+ }, factory.factoryId, 30_000, ['FACTORY_ACCEPT', 'ERROR']);
650
+ if (reply?.type !== 'FACTORY_ACCEPT') {
651
+ throw new Error(`Counterparty did not accept the factory (${reply?.type ?? 'timeout'})`);
652
+ }
653
+ const accepted = reply.payload?.factory;
654
+ if (!accepted)
655
+ throw new Error('Factory accept reply missing factory');
656
+ factory = accepted;
657
+ }
440
658
  context.factories.set(factory.factoryId, factory);
441
- return { success: true, factoryId: factory.factoryId, fundingTxId: factory.fundingTxId };
659
+ return { success: true, factoryId: factory.factoryId, status: factory.status };
442
660
  });
443
661
  };
444
662
  register('totem_omniaCreateFactory', createFactoryMethod, 'omnia/createFactory');
@@ -457,14 +675,109 @@ function createHostMethods(context) {
457
675
  if (!amounts || typeof amounts !== 'object')
458
676
  throw new Error('amounts is required');
459
677
  const parsedAmounts = Object.fromEntries(Object.entries(amounts).map(([key, value]) => [key, BigInt(String(value))]));
678
+ const localPartyId = context.localParticipant?.partyId;
679
+ if (!localPartyId)
680
+ throw new Error('Local participant is not configured');
681
+ const localBundle = context.factoryBundles[localPartyId];
682
+ if (!localBundle)
683
+ throw new Error('No local factory lease bundle');
460
684
  return withOperation(context, params, async () => {
461
- const result = await (0, omnia_factory_1.openVirtualChannel)(factory, parties, parsedAmounts, context.factoryBundles);
685
+ const plan = (0, omnia_factory_1.buildVirtualChannelOpen)(factory, parties, parsedAmounts);
686
+ const localSig = await (0, omnia_factory_1.signVirtualChannelOpen)(factory, plan, localBundle);
687
+ const signatures = { [localSig.partyId]: localSig };
688
+ const remote = factory.participants.find((participant) => participant.partyId !== localPartyId);
689
+ const peer = context.swarm && remote
690
+ ? await context.swarm.connectToPeer(remote.publicKeyDigest, plan.vcId, remote.transportPubkey)
691
+ : undefined;
692
+ if (peer) {
693
+ const reply = await requestPeerReply(peer, {
694
+ type: 'VC_PROPOSAL',
695
+ channelId: plan.vcId,
696
+ nonce: Date.now(),
697
+ payload: { kind: 'vc-open', factoryId, vcId: plan.vcId, plan },
698
+ }, plan.vcId, 30_000, ['VC_ACCEPT', 'ERROR']);
699
+ const sig = reply?.payload?.signature;
700
+ if (reply?.type !== 'VC_ACCEPT' || !sig) {
701
+ throw new Error(`Counterparty did not sign the virtual-channel open (${reply?.type ?? 'timeout'})`);
702
+ }
703
+ signatures[sig.partyId] = sig;
704
+ }
705
+ const result = (0, omnia_factory_1.assembleVirtualChannelOpen)(factory, plan, signatures);
706
+ await localBundle.leaseProvider.commitKeyUse(localSig.reservationId, `factory-vc-open-${factoryId}`);
707
+ if (peer) {
708
+ await peer.sendMessage({
709
+ type: 'VC_FINALIZED',
710
+ channelId: plan.vcId,
711
+ nonce: Date.now(),
712
+ payload: { kind: 'vc-open', factory: result.factory, channel: result.channel },
713
+ });
714
+ }
462
715
  context.factories.set(factoryId, result.factory);
463
716
  context.channels.set(result.channel.channelId, result.channel);
464
717
  return { success: true, channelId: result.channel.channelId, factoryId };
465
718
  });
466
719
  };
467
720
  register('totem_omniaOpenVirtualChannel', openVirtual, 'omnia/openVirtualChannel');
721
+ const closeVirtual = async (raw) => {
722
+ const params = paramsObject(raw);
723
+ if (!context.factories || !context.factoryBundles)
724
+ throw new Error('Factory manager is not configured');
725
+ const factoryId = requiredString(params, 'factoryId');
726
+ const channelId = requiredString(params, 'channelId');
727
+ const factory = context.factories.get(factoryId);
728
+ if (!factory)
729
+ throw new Error(`Factory ${factoryId} not found`);
730
+ const channel = context.channels.get(channelId);
731
+ if (!channel)
732
+ throw new Error(`Channel ${channelId} not found`);
733
+ const localPartyId = context.localParticipant?.partyId;
734
+ if (!localPartyId)
735
+ throw new Error('Local participant is not configured');
736
+ const localBundle = context.factoryBundles[localPartyId];
737
+ if (!localBundle)
738
+ throw new Error('No local factory lease bundle');
739
+ return withOperation(context, params, async () => {
740
+ const plan = (0, omnia_factory_1.buildVirtualChannelClose)(factory, channel);
741
+ const localSig = await (0, omnia_factory_1.signVirtualChannelClose)(factory, plan, localBundle);
742
+ const signatures = { [localSig.partyId]: localSig };
743
+ const remote = factory.participants.find((participant) => participant.partyId !== localPartyId);
744
+ const peer = context.swarm && remote
745
+ ? await context.swarm.connectToPeer(remote.publicKeyDigest, plan.vcId, remote.transportPubkey)
746
+ : undefined;
747
+ if (peer) {
748
+ const reply = await requestPeerReply(peer, {
749
+ type: 'VC_PROPOSAL',
750
+ channelId: plan.vcId,
751
+ nonce: Date.now(),
752
+ payload: { kind: 'vc-close', factoryId, vcId: plan.vcId, plan },
753
+ }, plan.vcId, 30_000, ['VC_ACCEPT', 'ERROR']);
754
+ const sig = reply?.payload?.signature;
755
+ if (reply?.type !== 'VC_ACCEPT' || !sig) {
756
+ throw new Error(`Counterparty did not sign the virtual-channel close (${reply?.type ?? 'timeout'})`);
757
+ }
758
+ signatures[sig.partyId] = sig;
759
+ }
760
+ const updated = (0, omnia_factory_1.assembleVirtualChannelClose)(factory, plan, signatures);
761
+ await localBundle.leaseProvider.commitKeyUse(localSig.reservationId, `factory-vc-close-${factoryId}`);
762
+ if (peer) {
763
+ await peer.sendMessage({
764
+ type: 'VC_FINALIZED',
765
+ channelId: plan.vcId,
766
+ nonce: Date.now(),
767
+ payload: { kind: 'vc-close', factory: updated, channelId },
768
+ });
769
+ }
770
+ context.factories.set(factoryId, updated);
771
+ context.channels.delete(channelId);
772
+ return {
773
+ success: true,
774
+ factoryId,
775
+ channelId,
776
+ allocations: Object.fromEntries(Object.entries(updated.allocations).map(([k, v]) => [k, v.toString()])),
777
+ };
778
+ });
779
+ };
780
+ register('totem_omniaCloseVirtualChannel', closeVirtual, 'omnia/closeVirtualChannel');
468
781
  const closeFactoryMethod = async (raw) => {
469
782
  const params = paramsObject(raw);
470
783
  if (!context.factories || !context.factoryBundles || !context.chainProvider)
@@ -473,29 +786,116 @@ function createHostMethods(context) {
473
786
  const factory = context.factories.get(factoryId);
474
787
  if (!factory)
475
788
  throw new Error(`Factory ${factoryId} not found`);
789
+ const coinProofRaw = params.coinProof
790
+ ?? params.broadcastProofs?.coinProofs;
791
+ const coinProofHex = Array.isArray(coinProofRaw) ? coinProofRaw[0] : coinProofRaw;
792
+ const coinProof = typeof coinProofHex === 'string'
793
+ ? Buffer.from(coinProofHex.replace(/^0x/i, ''), 'hex')
794
+ : undefined;
476
795
  return withOperation(context, params, async () => {
477
- const payload = await (0, omnia_factory_1.closeFactory)(factory, context.factoryBundles, context.chainProvider);
796
+ const localPartyId = context.localParticipant?.partyId;
797
+ const localBundle = localPartyId ? context.factoryBundles[localPartyId] : undefined;
798
+ if (!localPartyId || !localBundle)
799
+ throw new Error('No local factory lease bundle');
800
+ const remote = factory.participants.find((participant) => participant.partyId !== localPartyId);
801
+ let payload;
802
+ if (context.swarm && remote) {
803
+ // Cooperative close: build/sign locally, collect the counterparty
804
+ // signature over the same draft, then assemble and broadcast once.
805
+ const draft = (0, omnia_factory_1.buildFactorySettlement)(factory);
806
+ const localSig = await (0, omnia_factory_1.signFactorySettlement)(factory, draft, localBundle);
807
+ const signatures = { [localSig.partyId]: localSig };
808
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, factoryId, remote.transportPubkey);
809
+ const message = {
810
+ type: 'FACTORY_CLOSE_PROPOSAL',
811
+ channelId: factoryId,
812
+ nonce: Date.now(),
813
+ payload: { factoryId, draft },
814
+ };
815
+ const reply = await requestPeerReply(peer, message, factoryId, 30_000, ['FACTORY_CLOSE_ACCEPT', 'ERROR']);
816
+ const remoteSig = reply?.payload?.signature;
817
+ if (reply?.type !== 'FACTORY_CLOSE_ACCEPT' || !remoteSig) {
818
+ throw new Error(`Counterparty did not co-sign the factory close (${reply?.type ?? 'timeout'})`);
819
+ }
820
+ signatures[remoteSig.partyId] = remoteSig;
821
+ payload = await (0, omnia_factory_1.assembleFactorySettlement)(factory, draft, signatures, context.chainProvider, { coinProof });
822
+ await localBundle.leaseProvider.commitKeyUse(localSig.reservationId, `factory-settlement-${factoryId}`);
823
+ await peer.sendMessage({
824
+ type: 'FACTORY_CLOSE_FINALIZED',
825
+ channelId: factoryId,
826
+ nonce: message.nonce,
827
+ payload: { factoryId, txId: payload.txpowId },
828
+ });
829
+ }
830
+ else {
831
+ payload = await (0, omnia_factory_1.closeFactory)(factory, context.factoryBundles, context.chainProvider, { coinProof });
832
+ }
478
833
  context.factories.set(factoryId, { ...factory, status: 'closed' });
479
- return { success: true, factoryId, settlementTxId: payload.txpowId, finalAllocations: Object.fromEntries(Object.entries(payload.finalAllocations).map(([key, value]) => [key, value.toString()])) };
834
+ const finalAllocations = payload.finalAllocations;
835
+ return { success: true, factoryId, settlementTxId: payload.txpowId, finalAllocations: Object.fromEntries(Object.entries(finalAllocations).map(([key, value]) => [key, value.toString()])) };
480
836
  });
481
837
  };
482
838
  register('totem_omniaCloseFactory', closeFactoryMethod, 'omnia/closeFactory');
483
839
  const splice = (type) => async (raw) => {
484
840
  const params = paramsObject(raw);
485
- if (!context.spliceLeaseProvider || !context.spliceProposals)
841
+ if (!context.spliceLeaseProvider || !context.spliceProposals || !context.spliceAcceptances) {
486
842
  throw new Error('Splice manager is not configured');
843
+ }
487
844
  const channelId = requiredString(params, 'channelId');
488
845
  const channel = context.channels.get(channelId);
489
846
  if (!channel)
490
847
  throw new Error(`Channel ${channelId} not found`);
848
+ const localPartyId = context.localParticipant?.partyId;
849
+ if (!localPartyId)
850
+ throw new Error('Local participant is not configured');
851
+ const remote = channel.parties.find((party) => party.partyId !== localPartyId);
852
+ if (!context.swarm || !remote)
853
+ throw new Error('Splice requires a counterparty');
854
+ const swarm = context.swarm;
491
855
  return withOperation(context, params, async () => {
492
- const quiesced = await (0, omnia_splice_1.quiesceChannel)(channel, context.spliceLeaseProvider);
856
+ const provider = context.spliceLeaseProvider;
857
+ const quiesced = await (0, omnia_splice_1.quiesceChannel)(channel, provider);
493
858
  const proposal = type === 'in'
494
- ? await (0, omnia_splice_1.proposeSpliceIn)(quiesced, requiredString(params, 'additionalCoinId'), requiredBigInt(params, 'additionalAmount'), context.spliceLeaseProvider, params.newBalances)
495
- : await (0, omnia_splice_1.proposeSpliceOut)(quiesced, requiredBigInt(params, 'withdrawAmount'), requiredString(params, 'withdrawAddress'), context.spliceLeaseProvider, params.newBalances);
859
+ ? await (0, omnia_splice_1.proposeSpliceIn)(quiesced, requiredString(params, 'additionalCoinId'), requiredBigInt(params, 'additionalAmount'), provider, params.newBalances)
860
+ : await (0, omnia_splice_1.proposeSpliceOut)(quiesced, requiredBigInt(params, 'withdrawAmount'), requiredString(params, 'withdrawAddress'), provider, params.newBalances);
496
861
  context.spliceProposals.set(proposal.spliceId, proposal);
497
- context.channels.set(channelId, quiesced);
498
- return { success: true, channelId, spliceId: proposal.spliceId, updatedChannelState: proposal.spliceTxHex };
862
+ const peer = await swarm.connectToPeer(remote.publicKeyDigest, channelId, remote.transportPubkey);
863
+ const proposalMessage = {
864
+ type: 'SPLICE_PROPOSAL',
865
+ channelId,
866
+ nonce: Date.now(),
867
+ payload: { proposal },
868
+ };
869
+ const reply = await requestPeerReply(peer, proposalMessage, channelId, 30_000, ['SPLICE_ACCEPT', 'ERROR']);
870
+ const acceptance = reply?.payload?.acceptance;
871
+ if (reply?.type !== 'SPLICE_ACCEPT' || !acceptance) {
872
+ throw new Error(`Counterparty did not accept the splice (${reply?.type ?? 'timeout'})`);
873
+ }
874
+ context.spliceAcceptances.set(proposal.spliceId, acceptance);
875
+ const spliced = await (0, omnia_splice_1.finalizeSplice)(quiesced, proposal, acceptance, {
876
+ ...(provider.broadcast ? { broadcast: provider.broadcast } : {}),
877
+ proposerLeaseProvider: provider.wotsLease,
878
+ });
879
+ context.channels.set(channelId, spliced);
880
+ await peer.sendMessage({
881
+ type: 'SPLICE_FINALIZED',
882
+ channelId,
883
+ nonce: proposalMessage.nonce,
884
+ payload: {
885
+ channel: spliced,
886
+ txId: spliced.spliceFundingTxId,
887
+ spliceId: proposal.spliceId,
888
+ },
889
+ });
890
+ return {
891
+ success: true,
892
+ channelId,
893
+ spliceId: proposal.spliceId,
894
+ spliceType: spliced.spliceType,
895
+ totalValue: spliced.totalValue.toString(),
896
+ balances: Object.fromEntries(Object.entries(spliced.balances).map(([partyId, amount]) => [partyId, amount.toString()])),
897
+ fundingCoinId: spliced.spliceFundingCoinId,
898
+ };
499
899
  });
500
900
  };
501
901
  register('totem_omniaSpliceIn', splice('in'), 'omnia/spliceIn');
@@ -16,6 +16,12 @@ export interface ConfirmationOptions {
16
16
  }
17
17
  export interface TotemNodeAdapter extends ChainStateProvider {
18
18
  getMode(): Promise<string | undefined>;
19
+ /**
20
+ * Sign arbitrary data with a wallet key (`sign publickey:<0x> data:<0x>`).
21
+ * Used to produce the funding witness signature: createChannel builds the
22
+ * funding digest, and this signs it with the funding coin's key.
23
+ */
24
+ signData(pubkeyHex: string, dataHex: string): Promise<string>;
19
25
  waitForConfirmation(coinId: string, options?: ConfirmationOptions): Promise<void>;
20
26
  subscribe(listener: (tip: ChainTip) => void): () => void;
21
27
  close(): void;
@@ -37,6 +37,10 @@ function createTotemNodeAdapter(options) {
37
37
  searchTokens: (query) => provider.searchTokens(query),
38
38
  getTokensByCreator: (address) => provider.getTokensByCreator(address),
39
39
  broadcastTxPoW: (txpowHex) => provider.broadcastTxPoW(txpowHex),
40
+ getMinTxPowWork: () => provider.getMinTxPowWork ? provider.getMinTxPowWork() : Promise.resolve(null),
41
+ ...(provider.submitTransaction
42
+ ? { submitTransaction: provider.submitTransaction.bind(provider) }
43
+ : {}),
40
44
  async getMode() {
41
45
  const result = await client.runCommand('getmode');
42
46
  if (!result || typeof result !== 'object')
@@ -44,6 +48,21 @@ function createTotemNodeAdapter(options) {
44
48
  const mode = result.mode;
45
49
  return typeof mode === 'string' ? mode : undefined;
46
50
  },
51
+ async signData(pubkeyHex, dataHex) {
52
+ const result = await client.runCommand('sign', { publickey: pubkeyHex, data: dataHex });
53
+ const unwrap = (v) => {
54
+ if (typeof v === 'string')
55
+ return v;
56
+ if (v && typeof v === 'object' && typeof v.response === 'string') {
57
+ return v.response;
58
+ }
59
+ return undefined;
60
+ };
61
+ const sig = unwrap(result) ?? unwrap(result?.response);
62
+ if (!sig)
63
+ throw new Error('signData: unexpected node response');
64
+ return sig;
65
+ },
47
66
  async waitForConfirmation(coinId, confirmationOptions = {}) {
48
67
  const timeoutMs = confirmationOptions.timeoutMs ?? 120_000;
49
68
  const interval = confirmationOptions.pollIntervalMs ?? pollIntervalMs;
package/dist/cli.js CHANGED
@@ -16,6 +16,8 @@ async function main() {
16
16
  ? {
17
17
  signer: signing.signer,
18
18
  leaseProvider: signing.leaseProvider,
19
+ transportKeyPair: signing.transportKeyPair,
20
+ transportPubkey: signing.transportPubkey,
19
21
  identity: identityAndManifest?.identity
20
22
  ? {
21
23
  address: signing.address,
package/dist/config.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface OmniaHostConfig {
11
11
  localAddressIndex?: number;
12
12
  localSettlementAddress?: string;
13
13
  nodeMode?: string;
14
+ /**
15
+ * Public key (0x…) that owns the funding coin. When set, the host signs the
16
+ * funding-transaction digest via the node's `sign` command to assemble the
17
+ * channel funding witness (OMNIA_FUNDING_PUBKEY).
18
+ */
19
+ fundingPubkey?: string;
14
20
  wsPath: string;
15
21
  /** BIP39 mnemonic or hex seed (same formats @totemsdk/core accepts). */
16
22
  seed?: string;
package/dist/config.js CHANGED
@@ -75,6 +75,7 @@ function loadConfigFromEnv(env = process.env) {
75
75
  localAddressIndex: optionalInteger(env.OMNIA_LOCAL_ADDRESS_INDEX),
76
76
  localSettlementAddress: optional(env.OMNIA_LOCAL_SETTLEMENT_ADDRESS),
77
77
  nodeMode: optional(env.OMNIA_NODE_MODE),
78
+ fundingPubkey: optional(env.OMNIA_FUNDING_PUBKEY),
78
79
  wsPath: env.OMNIA_WS_PATH?.trim() || '/rpc',
79
80
  seed,
80
81
  keyfile,
package/dist/identity.js CHANGED
@@ -91,6 +91,7 @@ async function createHostManifest(config, signing, identity) {
91
91
  endpoints: [{ type: 'websocket', uri: `ws://${config.host}:${config.port}${config.wsPath}` }],
92
92
  capabilities: ['omnia:open-channel', 'omnia:update-channel', 'omnia:route', 'omnia:settle'],
93
93
  tags: ['omnia', 'payment-channel', 'daemon'],
94
+ transportPubkey: signing.transportPubkey,
94
95
  };
95
96
  // RFC-037: sign the manifest with the unified child TreeKey. Allocate a
96
97
  // one-time leaf via the lease so restarts never reuse a WOTS leaf.
@@ -24,6 +24,13 @@ export interface OmniaHostDependencies {
24
24
  methods?: Map<string, JsonRpcHandler>;
25
25
  operationStoreForApi?: OperationStoreLike;
26
26
  localParticipant?: import('@totemsdk/omnia').ChannelParticipant;
27
+ /** Stable ed25519 native transport keypair (derived from the host seed). */
28
+ transportKeyPair?: {
29
+ publicKey: Uint8Array;
30
+ secretKey: Uint8Array;
31
+ };
32
+ /** `0x`-prefixed transport public key published in the signed manifest. */
33
+ transportPubkey?: string;
27
34
  factories?: Map<string, ChannelFactory>;
28
35
  factoryBundles?: Record<string, WotsLeaseBundle>;
29
36
  spliceProposals?: Map<string, SpliceProposal>;
package/dist/lifecycle.js CHANGED
@@ -8,6 +8,9 @@ const jsonrpc_js_1 = require("./api/jsonrpc.js");
8
8
  const totem_node_adapter_js_1 = require("./chain/totem-node-adapter.js");
9
9
  const routing_provider_js_1 = require("./router/routing-provider.js");
10
10
  const methods_js_1 = require("./api/methods.js");
11
+ const reply_cache_js_1 = require("./reply-cache.js");
12
+ const omnia_factory_1 = require("@totemsdk/omnia-factory");
13
+ const omnia_splice_1 = require("@totemsdk/omnia-splice");
11
14
  /**
12
15
  * Phase-1 lifecycle shell. Subsystems will be attached in later phases while
13
16
  * preserving idempotent startup and shutdown for the CLI and embedding users.
@@ -54,9 +57,23 @@ function createOmniaHost(config, dependencies = {}) {
54
57
  const relay = config.relay
55
58
  ? { mode: 'self-hosted', relayUrl: config.relay }
56
59
  : { mode: 'native' };
60
+ const dhtBootstrap = (process.env.OMNIA_DHT_BOOTSTRAP ?? '')
61
+ .split(',')
62
+ .map((s) => s.trim())
63
+ .filter(Boolean);
64
+ // Serverless native mode: bind/announce an explicit local interface so
65
+ // peers can reach us directly (multi-homed hosts, netns, VPN, etc.).
66
+ const dhtHost = process.env.OMNIA_DHT_HOST?.trim() || undefined;
67
+ const dhtPort = process.env.OMNIA_DHT_PORT
68
+ ? Number(process.env.OMNIA_DHT_PORT)
69
+ : undefined;
57
70
  swarm = await (dependencies.createSwarm ?? omnia_1.createOmniaSwarm)({
58
71
  localPubkey: config.localPubkey,
59
72
  relay,
73
+ ...(dhtBootstrap.length ? { bootstrap: dhtBootstrap } : {}),
74
+ ...(dependencies.transportKeyPair ? { keyPair: dependencies.transportKeyPair } : {}),
75
+ ...(dhtHost ? { host: dhtHost } : {}),
76
+ ...(dhtPort ? { port: dhtPort } : {}),
60
77
  });
61
78
  }
62
79
  if (!chainAdapter) {
@@ -76,10 +93,238 @@ function createOmniaHost(config, dependencies = {}) {
76
93
  settlementAddress: config.localSettlementAddress,
77
94
  }
78
95
  : undefined);
96
+ // Factory managers default to this node's own lease bundle so the local
97
+ // side can propose/sign without extra wiring. A two-host factory still
98
+ // needs peer coordination (FACTORY_PROPOSAL/ACCEPT).
99
+ const factoryBundles = dependencies.factoryBundles ?? (localParticipant && dependencies.signer && dependencies.leaseProvider
100
+ ? {
101
+ [localParticipant.partyId]: {
102
+ signer: dependencies.signer,
103
+ leaseProvider: dependencies.leaseProvider,
104
+ },
105
+ }
106
+ : undefined);
107
+ const factories = dependencies.factories ?? (factoryBundles ? new Map() : undefined);
108
+ const localBundle = localParticipant
109
+ ? factoryBundles?.[localParticipant.partyId]
110
+ : undefined;
111
+ const spliceLeaseProvider = dependencies.spliceLeaseProvider ?? (dependencies.signer && dependencies.leaseProvider
112
+ ? {
113
+ signer: dependencies.signer,
114
+ wotsLease: dependencies.leaseProvider,
115
+ ...(chainAdapter
116
+ ? { broadcast: (txHex) => chainAdapter.broadcastTxPoW(txHex) }
117
+ : {}),
118
+ }
119
+ : undefined);
120
+ const spliceProposals = dependencies.spliceProposals ?? new Map();
121
+ const spliceAcceptances = dependencies.spliceAcceptances ?? new Map();
122
+ const onSpliceMessage = async (peer, msg) => {
123
+ if (!spliceLeaseProvider || !channelStore)
124
+ return;
125
+ if (msg?.type !== 'SPLICE_PROPOSAL' && msg?.type !== 'SPLICE_FINALIZED')
126
+ return;
127
+ const payload = (msg.payload ?? {});
128
+ try {
129
+ if (msg.type === 'SPLICE_PROPOSAL' && payload.proposal) {
130
+ const proposal = payload.proposal;
131
+ let channel = channelStore.get(proposal.channelId);
132
+ if (!channel)
133
+ throw new Error(`Splice channel ${proposal.channelId} not found`);
134
+ const prior = spliceAcceptances.get(proposal.spliceId);
135
+ if (prior) {
136
+ await peer.sendMessage({
137
+ type: 'SPLICE_ACCEPT',
138
+ channelId: msg.channelId,
139
+ nonce: msg.nonce,
140
+ payload: { acceptance: prior },
141
+ });
142
+ return;
143
+ }
144
+ if (channel.status === 'active') {
145
+ channel = await (0, omnia_splice_1.quiesceChannel)(channel, spliceLeaseProvider);
146
+ channelStore.set(channel.channelId, channel);
147
+ }
148
+ const acceptance = await (0, omnia_splice_1.acceptSplice)(channel, proposal, spliceLeaseProvider);
149
+ spliceAcceptances.set(proposal.spliceId, acceptance);
150
+ await peer.sendMessage({
151
+ type: 'SPLICE_ACCEPT',
152
+ channelId: msg.channelId,
153
+ nonce: msg.nonce,
154
+ payload: { acceptance },
155
+ });
156
+ return;
157
+ }
158
+ if (msg.type === 'SPLICE_FINALIZED' && payload.channel) {
159
+ const channel = payload.channel;
160
+ channelStore.set(channel.channelId, channel);
161
+ const acceptance = spliceAcceptances.get(payload.spliceId);
162
+ if (acceptance && typeof payload.txId === 'string') {
163
+ await spliceLeaseProvider.wotsLease
164
+ .commitKeyUse(acceptance.acceptorReservationId, payload.txId)
165
+ .catch(() => { });
166
+ }
167
+ }
168
+ }
169
+ catch (err) {
170
+ await peer.sendMessage({
171
+ type: 'ERROR',
172
+ channelId: msg.channelId,
173
+ nonce: msg.nonce,
174
+ payload: { error: err instanceof Error ? err.message : String(err) },
175
+ }).catch(() => { });
176
+ }
177
+ };
178
+ const factoryReplies = new reply_cache_js_1.ReplyCache();
179
+ const vcReplies = new reply_cache_js_1.ReplyCache();
180
+ const onFactoryMessage = async (peer, msg) => {
181
+ if (!factories || !localBundle)
182
+ return;
183
+ if (msg?.type !== 'FACTORY_PROPOSAL' &&
184
+ msg?.type !== 'FACTORY_FUNDING_REQUEST' &&
185
+ msg?.type !== 'FACTORY_CLOSE_PROPOSAL' &&
186
+ msg?.type !== 'FACTORY_CLOSE_FINALIZED')
187
+ return;
188
+ const payload = (msg?.payload ?? {});
189
+ if (process.env.OMNIA_DEBUG) {
190
+ console.error('[factory] recv', msg.type, 'hasFactory', !!payload.factory, 'bundles', !!localBundle);
191
+ }
192
+ const key = factoryReplies.key(msg.type, msg.channelId, msg.nonce);
193
+ const reply = await factoryReplies.run(key, async () => {
194
+ try {
195
+ if (msg.type === 'FACTORY_FUNDING_REQUEST' && typeof payload.digest === 'string') {
196
+ const digest = Uint8Array.from(Buffer.from(payload.digest.replace(/^0x/i, ''), 'hex'));
197
+ const reservation = await localBundle.leaseProvider.reserveKeyUse({
198
+ treeId: 'factory-funding',
199
+ purpose: 'factory-funding',
200
+ payloadHash: payload.digest,
201
+ });
202
+ try {
203
+ const signature = await localBundle.signer.sign(digest, reservation.indices);
204
+ await localBundle.leaseProvider.commitKeyUse(reservation.reservationId, 'factory-funding');
205
+ return {
206
+ type: 'FACTORY_FUNDING_RESPONSE',
207
+ channelId: msg.channelId,
208
+ nonce: msg.nonce,
209
+ payload: { signature: '0x' + Buffer.from(signature).toString('hex') },
210
+ };
211
+ }
212
+ catch (err) {
213
+ await localBundle.leaseProvider.burnReservation(reservation.reservationId, 'factory-funding-failed').catch(() => { });
214
+ throw err;
215
+ }
216
+ }
217
+ if (msg.type === 'FACTORY_PROPOSAL' && payload.factory) {
218
+ const accepted = await (0, omnia_factory_1.acceptFactory)(payload.factory, localBundle);
219
+ factories.set(accepted.factoryId, accepted);
220
+ return {
221
+ type: 'FACTORY_ACCEPT',
222
+ channelId: accepted.factoryId,
223
+ nonce: msg.nonce,
224
+ payload: { factory: accepted },
225
+ };
226
+ }
227
+ if (msg.type === 'FACTORY_CLOSE_PROPOSAL' && payload.factoryId) {
228
+ const factory = factories.get(payload.factoryId);
229
+ if (!factory)
230
+ throw new Error(`Factory ${payload.factoryId} not found`);
231
+ const signature = await (0, omnia_factory_1.signFactorySettlement)(factory, payload.draft, localBundle);
232
+ await localBundle.leaseProvider
233
+ .commitKeyUse(signature.reservationId, `factory-settlement-${payload.factoryId}`)
234
+ .catch(() => { });
235
+ return {
236
+ type: 'FACTORY_CLOSE_ACCEPT',
237
+ channelId: payload.factoryId,
238
+ nonce: msg.nonce,
239
+ payload: { signature },
240
+ };
241
+ }
242
+ if (msg.type === 'FACTORY_CLOSE_FINALIZED' && payload.factoryId) {
243
+ const factory = factories.get(payload.factoryId);
244
+ if (factory)
245
+ factories.set(payload.factoryId, { ...factory, status: 'closed' });
246
+ return null;
247
+ }
248
+ return null;
249
+ }
250
+ catch (e) {
251
+ if (process.env.OMNIA_DEBUG)
252
+ console.error('[factory] err', String(e));
253
+ return {
254
+ type: 'ERROR',
255
+ channelId: msg.channelId,
256
+ nonce: msg.nonce,
257
+ payload: { error: String(e) },
258
+ };
259
+ }
260
+ });
261
+ if (reply)
262
+ await peer.sendMessage(reply);
263
+ };
264
+ const onVcMessage = async (peer, msg) => {
265
+ if (!factories || !localBundle)
266
+ return;
267
+ const payload = (msg?.payload ?? {});
268
+ if (msg?.type === 'VC_FINALIZED') {
269
+ if (payload.factory)
270
+ factories.set(payload.factory.factoryId, payload.factory);
271
+ if (payload.kind === 'vc-open' && payload.channel) {
272
+ channelStore?.set(payload.channel.channelId, payload.channel);
273
+ }
274
+ else if (payload.kind === 'vc-close' && payload.channelId) {
275
+ channelStore?.delete(payload.channelId);
276
+ }
277
+ return;
278
+ }
279
+ if (msg?.type !== 'VC_PROPOSAL')
280
+ return;
281
+ const key = vcReplies.key(msg.type, msg.channelId, msg.nonce, payload.kind);
282
+ const reply = await vcReplies.run(key, async () => {
283
+ try {
284
+ const factory = factories.get(payload.factoryId);
285
+ if (!factory)
286
+ throw new Error(`Factory ${payload.factoryId} not found`);
287
+ if (payload.kind === 'vc-open') {
288
+ const signature = await (0, omnia_factory_1.signVirtualChannelOpen)(factory, payload.plan, localBundle);
289
+ await localBundle.leaseProvider.commitKeyUse(signature.reservationId, `factory-vc-open-${payload.factoryId}`);
290
+ return {
291
+ type: 'VC_ACCEPT',
292
+ channelId: payload.vcId,
293
+ nonce: msg.nonce,
294
+ payload: { kind: 'vc-open', signature },
295
+ };
296
+ }
297
+ if (payload.kind === 'vc-close') {
298
+ const signature = await (0, omnia_factory_1.signVirtualChannelClose)(factory, payload.plan, localBundle);
299
+ await localBundle.leaseProvider.commitKeyUse(signature.reservationId, `factory-vc-close-${payload.factoryId}`);
300
+ return {
301
+ type: 'VC_ACCEPT',
302
+ channelId: payload.vcId,
303
+ nonce: msg.nonce,
304
+ payload: { kind: 'vc-close', signature },
305
+ };
306
+ }
307
+ return null;
308
+ }
309
+ catch (e) {
310
+ return {
311
+ type: 'ERROR',
312
+ channelId: msg.channelId,
313
+ nonce: msg.nonce,
314
+ payload: { error: String(e) },
315
+ };
316
+ }
317
+ });
318
+ if (reply)
319
+ await peer.sendMessage(reply);
320
+ };
79
321
  unsubscribe = (0, omnia_1.createOmniaIntegration)(swarm, channelStore, {
80
322
  signer: dependencies.signer,
81
323
  leaseProvider: dependencies.leaseProvider,
82
324
  chainProvider: dependencies.chainProvider ?? chainAdapter,
325
+ onFactoryMessage,
326
+ onVcMessage,
327
+ onSpliceMessage,
83
328
  });
84
329
  if (!controlServer) {
85
330
  const readOnly = !dependencies.signer || !dependencies.leaseProvider;
@@ -102,11 +347,18 @@ function createOmniaHost(config, dependencies = {}) {
102
347
  chainProvider: dependencies.chainProvider ?? chainAdapter,
103
348
  swarm,
104
349
  localParticipant,
105
- factories: dependencies.factories,
106
- factoryBundles: dependencies.factoryBundles,
107
- spliceProposals: dependencies.spliceProposals,
108
- spliceAcceptances: dependencies.spliceAcceptances,
109
- spliceLeaseProvider: dependencies.spliceLeaseProvider,
350
+ transportPubkey: dependencies.transportPubkey,
351
+ factories,
352
+ factoryBundles,
353
+ spliceProposals,
354
+ spliceAcceptances,
355
+ spliceLeaseProvider,
356
+ fundingSigner: config.fundingPubkey
357
+ ? async (digest) => {
358
+ const sig = await chainAdapter.signData(config.fundingPubkey, '0x' + Buffer.from(digest).toString('hex'));
359
+ return Uint8Array.from(Buffer.from(sig.replace(/^0x/i, ''), 'hex'));
360
+ }
361
+ : undefined,
110
362
  readOnly,
111
363
  identity: dependencies.identity,
112
364
  manifest: dependencies.manifest,
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Idempotent request/reply support for peer proposals.
3
+ *
4
+ * A requester resends a proposal when the transport reconnects (the proposal or
5
+ * its reply may have been lost). The responder must then:
6
+ * - never sign/allocate a second time for the same logical request, and
7
+ * - return the exact same reply so the requester sees a consistent result.
8
+ *
9
+ * Keyed by `(type, kind, channelId, nonce)`; concurrent duplicates await the
10
+ * in-flight execution instead of starting a second one.
11
+ */
12
+ export declare class ReplyCache {
13
+ private readonly _maxEntries;
14
+ private readonly _ttlMs;
15
+ private readonly _replies;
16
+ private readonly _inFlight;
17
+ constructor(_maxEntries?: number, _ttlMs?: number);
18
+ key(type: string, channelId: string, nonce: number | undefined, kind?: string): string;
19
+ run<T>(key: string, fn: () => Promise<T | null>): Promise<T | null>;
20
+ private _evict;
21
+ }
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ReplyCache = void 0;
4
+ /**
5
+ * Idempotent request/reply support for peer proposals.
6
+ *
7
+ * A requester resends a proposal when the transport reconnects (the proposal or
8
+ * its reply may have been lost). The responder must then:
9
+ * - never sign/allocate a second time for the same logical request, and
10
+ * - return the exact same reply so the requester sees a consistent result.
11
+ *
12
+ * Keyed by `(type, kind, channelId, nonce)`; concurrent duplicates await the
13
+ * in-flight execution instead of starting a second one.
14
+ */
15
+ class ReplyCache {
16
+ _maxEntries;
17
+ _ttlMs;
18
+ _replies = new Map();
19
+ _inFlight = new Map();
20
+ constructor(_maxEntries = 512, _ttlMs = 10 * 60_000) {
21
+ this._maxEntries = _maxEntries;
22
+ this._ttlMs = _ttlMs;
23
+ }
24
+ key(type, channelId, nonce, kind) {
25
+ return [type, kind ?? '', channelId, nonce ?? ''].join(':');
26
+ }
27
+ async run(key, fn) {
28
+ const cached = this._replies.get(key);
29
+ if (cached) {
30
+ if (Date.now() - cached.at < this._ttlMs)
31
+ return cached.reply;
32
+ this._replies.delete(key);
33
+ }
34
+ const inFlight = this._inFlight.get(key);
35
+ if (inFlight)
36
+ return inFlight;
37
+ const promise = (async () => {
38
+ try {
39
+ const reply = await fn();
40
+ if (reply !== null) {
41
+ this._replies.set(key, { reply, at: Date.now() });
42
+ this._evict();
43
+ }
44
+ return reply;
45
+ }
46
+ finally {
47
+ this._inFlight.delete(key);
48
+ }
49
+ })();
50
+ this._inFlight.set(key, promise);
51
+ return promise;
52
+ }
53
+ _evict() {
54
+ while (this._replies.size > this._maxEntries) {
55
+ const oldest = this._replies.keys().next().value;
56
+ if (oldest === undefined)
57
+ break;
58
+ this._replies.delete(oldest);
59
+ }
60
+ }
61
+ }
62
+ exports.ReplyCache = ReplyCache;
package/dist/signing.d.ts CHANGED
@@ -24,6 +24,17 @@ import type { OmniaHostConfig } from './config.js';
24
24
  export interface HostSigning {
25
25
  signer: ChannelSigner;
26
26
  leaseProvider: WotsLeaseProvider;
27
+ /**
28
+ * Stable ed25519 keypair used as the native Hyperswarm transport identity.
29
+ * Derived deterministically from the host seed. The transport is classical
30
+ * (noise); its public key is bound to the PQ identity by the signed manifest.
31
+ */
32
+ transportKeyPair: {
33
+ publicKey: Uint8Array;
34
+ secretKey: Uint8Array;
35
+ };
36
+ /** `0x`-prefixed hex transport public key (published in the signed manifest). */
37
+ transportPubkey: string;
27
38
  /** Minima address for the unified child TreeKey (identity + channels). */
28
39
  address: string;
29
40
  /** Channel participant key: the unified child TreeKey root pubkey (RFC-037). */
package/dist/signing.js CHANGED
@@ -79,6 +79,29 @@ function encryptSeedForKeyfile(seed, passphrase) {
79
79
  authTag: cipher.getAuthTag().toString('hex'),
80
80
  };
81
81
  }
82
+ /**
83
+ * Derive the stable ed25519 transport keypair from the host seed.
84
+ *
85
+ * The transport is Hyperswarm's classical noise channel; this key is *not* the
86
+ * quantum-resistant identity. It is published in the identity-signed manifest
87
+ * so peers can bind it to the host's PQ identity.
88
+ */
89
+ function deriveTransportKeyPair(baseSeed) {
90
+ const seed = (0, node_crypto_1.createHash)('sha256')
91
+ .update(Buffer.concat([Buffer.from(baseSeed), Buffer.from('omnia-transport-v1')]))
92
+ .digest();
93
+ // Ed25519 PKCS#8 DER prefix + 32-byte seed
94
+ const pkcs8 = Buffer.concat([
95
+ Buffer.from('302e020100300506032b657004220420', 'hex'),
96
+ seed,
97
+ ]);
98
+ const privateKey = (0, node_crypto_1.createPrivateKey)({ key: pkcs8, format: 'der', type: 'pkcs8' });
99
+ const spki = (0, node_crypto_1.createPublicKey)(privateKey).export({ format: 'der', type: 'spki' });
100
+ const publicKey = spki.subarray(spki.length - 32);
101
+ // sodium/ed25519 64-byte secret key = seed || publicKey
102
+ const secretKey = Buffer.concat([seed, publicKey]);
103
+ return { publicKey: new Uint8Array(publicKey), secretKey: new Uint8Array(secretKey) };
104
+ }
82
105
  function resolveBaseSeed(config) {
83
106
  if (config.seed) {
84
107
  const trimmed = config.seed.trim();
@@ -194,11 +217,15 @@ function createHostSigning(config) {
194
217
  return (0, core_1.serializeTreeSignature)(treeKey.sign(payload));
195
218
  },
196
219
  };
220
+ const transportKeyPair = deriveTransportKeyPair(baseSeed);
221
+ const transportPubkey = `0x${(0, core_1.bytesToHex)(transportKeyPair.publicKey)}`;
197
222
  return {
198
223
  signer,
199
224
  leaseProvider,
200
225
  address,
201
226
  publicKeyDigest,
227
+ transportKeyPair,
228
+ transportPubkey,
202
229
  baseSeed,
203
230
  treeKey,
204
231
  addressIndex,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totemsdk/omnia-host",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Durable Omnia node daemon for channel lifecycle, routing, and control APIs",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,17 +22,17 @@
22
22
  "dependencies": {
23
23
  "better-sqlite3": "^12.2.0",
24
24
  "ws": "^8.21.1",
25
- "@totemsdk/chain-provider": "^0.2.1",
26
- "@totemsdk/identity": "^1.0.1",
27
- "@totemsdk/core": "^1.2.11",
28
- "@totemsdk/manifest": "^2.0.0",
29
- "@totemsdk/omnia": "^2.0.1",
30
- "@totemsdk/omnia-factory": "^0.5.0",
31
- "@totemsdk/omnia-router": "^0.3.0",
25
+ "@totemsdk/chain-provider": "^0.3.0",
26
+ "@totemsdk/core": "^1.4.0",
27
+ "@totemsdk/identity": "^1.0.2",
28
+ "@totemsdk/manifest": "^2.0.1",
29
+ "@totemsdk/omnia": "^2.3.0",
30
+ "@totemsdk/omnia-factory": "^0.7.0",
31
+ "@totemsdk/omnia-router": "^0.4.0",
32
32
  "@totemsdk/omnia-splice": "^0.3.0",
33
- "@totemsdk/omnia-vtxo": "^0.5.0",
34
- "@totemsdk/minima-rpc": "^0.2.2",
35
33
  "@totemsdk/storage": "^0.1.0",
34
+ "@totemsdk/omnia-vtxo": "^0.6.1",
35
+ "@totemsdk/minima-rpc": "^0.4.0",
36
36
  "@totemsdk/wots-lease": "^1.0.1"
37
37
  },
38
38
  "devDependencies": {
@@ -43,7 +43,7 @@
43
43
  "jest": "^30.4.2",
44
44
  "ts-jest": "^29.4.11",
45
45
  "typescript": "5.9.3",
46
- "@totemsdk/txpow": "^1.0.2"
46
+ "@totemsdk/txpow": "^1.1.0"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=20.0.0"