@totemsdk/omnia-host 0.9.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.
@@ -17,6 +17,8 @@ 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;
20
22
  /**
21
23
  * Signs the funding-transaction digest with the funding coin's key, so
22
24
  * openChannel can assemble the funding witness without the caller reproducing
@@ -45,4 +47,36 @@ export interface HostApiContext {
45
47
  * read everything they need without reaching into `getStore()`/`getMethods()`.
46
48
  */
47
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>;
48
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");
@@ -176,7 +177,7 @@ async function persistProgramUpdate(context, channelId, run) {
176
177
  context.channels.set(channelId, result.channel);
177
178
  const remoteParty = channel.parties.find((party) => party.partyId !== context.localParticipant.partyId);
178
179
  if (context.swarm && remoteParty) {
179
- const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId);
180
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey);
180
181
  await peer.sendMessage({ type: 'STATE_UPDATE', channelId, nonce: Date.now(), payload: result.signedState });
181
182
  }
182
183
  return {
@@ -187,29 +188,54 @@ async function persistProgramUpdate(context, channelId, run) {
187
188
  };
188
189
  }
189
190
  /**
190
- * Wait for an ACK/ERROR from the counterparty for a given channel. Returns the
191
- * reply message (so callers can read a co-signature) or null on timeout.
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.
192
198
  */
193
- function waitForPeerReply(peer, channelId, timeoutMs) {
199
+ function requestPeerReply(peer, msg, channelId, timeoutMs, types = ['ACK', 'ERROR']) {
194
200
  return new Promise((resolve) => {
195
201
  let done = false;
196
- let unsub;
202
+ let timer;
203
+ let fallback;
204
+ const unsubs = [];
197
205
  const finish = (reply) => {
198
206
  if (done)
199
207
  return;
200
208
  done = true;
201
- clearTimeout(timer);
202
- if (unsub)
209
+ if (timer)
210
+ clearTimeout(timer);
211
+ if (fallback)
212
+ clearTimeout(fallback);
213
+ for (const unsub of unsubs)
203
214
  unsub();
204
215
  resolve(reply);
205
216
  };
206
- unsub = peer.onMessage((msg) => {
207
- if (msg.channelId !== channelId)
217
+ const resend = (reason) => {
218
+ if (done)
208
219
  return;
209
- if (msg.type === 'ACK' || msg.type === 'ERROR')
210
- finish(msg);
211
- });
212
- const timer = setTimeout(() => finish(null), timeoutMs);
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(() => { });
213
239
  });
214
240
  }
215
241
  function createHostMethods(context) {
@@ -304,10 +330,14 @@ function createHostMethods(context) {
304
330
  publicKeyDigest: remotePublicKeyDigest,
305
331
  addressIndex: typeof params.remoteAddressIndex === 'number' ? params.remoteAddressIndex : 0,
306
332
  settlementAddress: typeof params.remoteSettlementAddress === 'string' ? params.remoteSettlementAddress : undefined,
333
+ transportPubkey: typeof params.remoteTransportPubkey === 'string' ? params.remoteTransportPubkey : undefined,
307
334
  };
308
335
  const result = await withOperation(context, params, async () => {
309
336
  const createParams = {
310
- localParty: context.localParticipant,
337
+ localParty: {
338
+ ...context.localParticipant,
339
+ transportPubkey: context.localParticipant.transportPubkey ?? context.transportPubkey,
340
+ },
311
341
  remoteParty: remote,
312
342
  localAmount: requiredBigInt(params, 'localAmount'),
313
343
  remoteAmount: requiredBigInt(params, 'remoteAmount'),
@@ -358,10 +388,9 @@ function createHostMethods(context) {
358
388
  });
359
389
  let acknowledged = true;
360
390
  if (context.swarm) {
361
- const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, created.channel.channelId);
362
- 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);
363
392
  // Give the peer time to accept so a follow-up pay() is not lost.
364
- const reply = await waitForPeerReply(peer, created.channel.channelId, 20_000);
393
+ const reply = await requestPeerReply(peer, { type: 'CHANNEL_PROPOSAL', channelId: created.channel.channelId, nonce: Date.now(), payload: created.proposal }, created.channel.channelId, 20_000);
365
394
  acknowledged = reply?.type === 'ACK';
366
395
  }
367
396
  return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId, acknowledged };
@@ -394,7 +423,8 @@ function createHostMethods(context) {
394
423
  if (result.error)
395
424
  throw new Error(result.error);
396
425
  context.channels.set(channelId, result.channel);
397
- 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;
398
428
  if (peer)
399
429
  await peer.sendMessage({ type: 'STATE_UPDATE', channelId, nonce: Date.now(), payload: result.signedState });
400
430
  return { success: true, channelId, sequence: result.channel.currentSequence, localBalance: result.channel.balances[localPartyId].toString(), remoteBalance: result.channel.balances[remotePartyId].toString() };
@@ -467,14 +497,13 @@ function createHostMethods(context) {
467
497
  let settlementTxHex;
468
498
  if (context.swarm && remoteParty) {
469
499
  const request = (0, omnia_1.buildSettlementSigningRequest)(closing, { partyAddresses });
470
- const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId);
471
- await peer.sendMessage({
500
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey);
501
+ const reply = await requestPeerReply(peer, {
472
502
  type: 'SETTLEMENT_PROPOSAL',
473
503
  channelId,
474
504
  nonce: Date.now(),
475
505
  payload: { signRequest: true, ...request },
476
- });
477
- const reply = await waitForPeerReply(peer, channelId, 30_000);
506
+ }, channelId, 30_000);
478
507
  const replyPayload = reply?.payload;
479
508
  if (reply?.type !== 'ACK' || typeof replyPayload?.signature !== 'string') {
480
509
  throw new Error(`Counterparty did not co-sign the settlement (${reply?.type ?? 'timeout'})`);
@@ -512,7 +541,7 @@ function createHostMethods(context) {
512
541
  register('totem_omniaCloseChannel', closeChannel, 'omnia/closeChannel');
513
542
  const createFactoryMethod = async (raw) => {
514
543
  const params = paramsObject(raw);
515
- if (!context.factories || !context.factoryBundles || !context.chainProvider)
544
+ if (!context.factories || !context.factoryBundles)
516
545
  throw new Error('Factory manager is not configured');
517
546
  const participants = params.participants;
518
547
  if (!Array.isArray(participants) || participants.length < 2)
@@ -528,17 +557,106 @@ function createHostMethods(context) {
528
557
  contributionAmount: BigInt(requiredString(item, 'contributionAmount')),
529
558
  fundingCoinId: typeof item.fundingCoinId === 'string' ? item.fundingCoinId : undefined,
530
559
  settlementAddress: typeof item.settlementAddress === 'string' ? item.settlementAddress : undefined,
560
+ transportPubkey: typeof item.transportPubkey === 'string' ? item.transportPubkey : undefined,
531
561
  };
532
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;
533
637
  const tokenId = typeof params.tokenId === 'string' ? params.tokenId : '0x00';
534
- const bundles = context.factoryBundles;
535
- const proposer = parsed.find((participant) => bundles[participant.partyId]);
536
- if (!proposer)
537
- throw new Error('No local factory lease bundle matches a participant');
538
638
  return withOperation(context, params, async () => {
539
- 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
+ }
540
658
  context.factories.set(factory.factoryId, factory);
541
- return { success: true, factoryId: factory.factoryId, fundingTxId: factory.fundingTxId };
659
+ return { success: true, factoryId: factory.factoryId, status: factory.status };
542
660
  });
543
661
  };
544
662
  register('totem_omniaCreateFactory', createFactoryMethod, 'omnia/createFactory');
@@ -557,14 +675,109 @@ function createHostMethods(context) {
557
675
  if (!amounts || typeof amounts !== 'object')
558
676
  throw new Error('amounts is required');
559
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');
560
684
  return withOperation(context, params, async () => {
561
- 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
+ }
562
715
  context.factories.set(factoryId, result.factory);
563
716
  context.channels.set(result.channel.channelId, result.channel);
564
717
  return { success: true, channelId: result.channel.channelId, factoryId };
565
718
  });
566
719
  };
567
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');
568
781
  const closeFactoryMethod = async (raw) => {
569
782
  const params = paramsObject(raw);
570
783
  if (!context.factories || !context.factoryBundles || !context.chainProvider)
@@ -573,29 +786,116 @@ function createHostMethods(context) {
573
786
  const factory = context.factories.get(factoryId);
574
787
  if (!factory)
575
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;
576
795
  return withOperation(context, params, async () => {
577
- 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
+ }
578
833
  context.factories.set(factoryId, { ...factory, status: 'closed' });
579
- 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()])) };
580
836
  });
581
837
  };
582
838
  register('totem_omniaCloseFactory', closeFactoryMethod, 'omnia/closeFactory');
583
839
  const splice = (type) => async (raw) => {
584
840
  const params = paramsObject(raw);
585
- if (!context.spliceLeaseProvider || !context.spliceProposals)
841
+ if (!context.spliceLeaseProvider || !context.spliceProposals || !context.spliceAcceptances) {
586
842
  throw new Error('Splice manager is not configured');
843
+ }
587
844
  const channelId = requiredString(params, 'channelId');
588
845
  const channel = context.channels.get(channelId);
589
846
  if (!channel)
590
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;
591
855
  return withOperation(context, params, async () => {
592
- 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);
593
858
  const proposal = type === 'in'
594
- ? await (0, omnia_splice_1.proposeSpliceIn)(quiesced, requiredString(params, 'additionalCoinId'), requiredBigInt(params, 'additionalAmount'), context.spliceLeaseProvider, params.newBalances)
595
- : 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);
596
861
  context.spliceProposals.set(proposal.spliceId, proposal);
597
- context.channels.set(channelId, quiesced);
598
- 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
+ };
599
899
  });
600
900
  };
601
901
  register('totem_omniaSpliceIn', splice('in'), 'omnia/spliceIn');
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/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,12 @@ 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,
110
356
  fundingSigner: config.fundingPubkey
111
357
  ? async (digest) => {
112
358
  const sig = await chainAdapter.signData(config.fundingPubkey, '0x' + Buffer.from(digest).toString('hex'));
@@ -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.9.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",
@@ -24,14 +24,14 @@
24
24
  "ws": "^8.21.1",
25
25
  "@totemsdk/chain-provider": "^0.3.0",
26
26
  "@totemsdk/core": "^1.4.0",
27
- "@totemsdk/identity": "^1.0.1",
28
- "@totemsdk/omnia": "^2.2.0",
29
- "@totemsdk/manifest": "^2.0.0",
30
- "@totemsdk/omnia-factory": "^0.6.0",
31
- "@totemsdk/omnia-router": "^0.3.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.6.0",
34
33
  "@totemsdk/storage": "^0.1.0",
34
+ "@totemsdk/omnia-vtxo": "^0.6.1",
35
35
  "@totemsdk/minima-rpc": "^0.4.0",
36
36
  "@totemsdk/wots-lease": "^1.0.1"
37
37
  },