@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.
- package/dist/api/methods.d.ts +34 -0
- package/dist/api/methods.js +338 -38
- package/dist/cli.js +2 -0
- package/dist/identity.js +1 -0
- package/dist/lifecycle.d.ts +7 -0
- package/dist/lifecycle.js +251 -5
- package/dist/reply-cache.d.ts +21 -0
- package/dist/reply-cache.js +62 -0
- package/dist/signing.d.ts +11 -0
- package/dist/signing.js +27 -0
- package/package.json +7 -7
package/dist/api/methods.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/api/methods.js
CHANGED
|
@@ -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
|
-
*
|
|
191
|
-
*
|
|
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
|
|
199
|
+
function requestPeerReply(peer, msg, channelId, timeoutMs, types = ['ACK', 'ERROR']) {
|
|
194
200
|
return new Promise((resolve) => {
|
|
195
201
|
let done = false;
|
|
196
|
-
let
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
if (
|
|
217
|
+
const resend = (reason) => {
|
|
218
|
+
if (done)
|
|
208
219
|
return;
|
|
209
|
-
if (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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'),
|
|
595
|
-
: await (0, omnia_splice_1.proposeSpliceOut)(quiesced, requiredBigInt(params, 'withdrawAmount'), requiredString(params, 'withdrawAddress'),
|
|
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
|
-
|
|
598
|
-
|
|
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.
|
package/dist/lifecycle.d.ts
CHANGED
|
@@ -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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
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.
|
|
28
|
-
"@totemsdk/
|
|
29
|
-
"@totemsdk/
|
|
30
|
-
"@totemsdk/omnia-factory": "^0.
|
|
31
|
-
"@totemsdk/omnia-router": "^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
|
},
|