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