@totemsdk/omnia-host 0.9.0 → 0.11.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.
@@ -1,11 +1,16 @@
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");
7
+ const omnia_vtxo_1 = require("@totemsdk/omnia-vtxo");
8
+ const omnia_pool_1 = require("@totemsdk/omnia-pool");
9
+ const liquidity_bond_1 = require("@totemsdk/liquidity-bond");
6
10
  const omnia_router_1 = require("@totemsdk/omnia-router");
7
11
  const omnia_factory_1 = require("@totemsdk/omnia-factory");
8
12
  const omnia_splice_1 = require("@totemsdk/omnia-splice");
13
+ const emitter_js_1 = require("../observe/emitter.js");
9
14
  function channelSummary(channel) {
10
15
  const parties = channel.parties.map((party) => party.partyId);
11
16
  return {
@@ -148,16 +153,36 @@ async function withOperation(context, params, run) {
148
153
  if (existing && !['pending', 'failed'].includes(existing.status)) {
149
154
  throw new Error(`Operation ${id} is already ${existing.status}`);
150
155
  }
151
- if (!existing)
156
+ if (!existing) {
152
157
  context.operations.create(id, params);
158
+ await context.observe?.emit({
159
+ domain: 'operation', kind: 'operation.created', phase: 'proposed',
160
+ entity: { type: 'operation', id }, operationId: id,
161
+ data: { request: params },
162
+ });
163
+ }
153
164
  context.operations.transition(id, existing?.status === 'failed' ? 'failed' : 'pending', 'executing');
165
+ await context.observe?.emit({
166
+ domain: 'operation', kind: 'operation.executing', phase: 'executing',
167
+ entity: { type: 'operation', id }, operationId: id,
168
+ });
154
169
  try {
155
170
  const result = await run();
156
171
  context.operations.transition(id, 'executing', 'committed', { result });
172
+ await context.observe?.emit({
173
+ domain: 'operation', kind: 'operation.committed', phase: 'committed',
174
+ entity: { type: 'operation', id }, operationId: id,
175
+ data: { result },
176
+ });
157
177
  return result;
158
178
  }
159
179
  catch (error) {
160
180
  context.operations.transition(id, 'executing', 'failed', { error: error instanceof Error ? error.message : String(error) });
181
+ await context.observe?.emit({
182
+ domain: 'operation', kind: 'operation.failed', phase: 'failed',
183
+ entity: { type: 'operation', id }, operationId: id,
184
+ data: { error: error instanceof Error ? error.message : String(error) },
185
+ });
161
186
  throw error;
162
187
  }
163
188
  }
@@ -176,7 +201,7 @@ async function persistProgramUpdate(context, channelId, run) {
176
201
  context.channels.set(channelId, result.channel);
177
202
  const remoteParty = channel.parties.find((party) => party.partyId !== context.localParticipant.partyId);
178
203
  if (context.swarm && remoteParty) {
179
- const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId);
204
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey);
180
205
  await peer.sendMessage({ type: 'STATE_UPDATE', channelId, nonce: Date.now(), payload: result.signedState });
181
206
  }
182
207
  return {
@@ -187,37 +212,1447 @@ async function persistProgramUpdate(context, channelId, run) {
187
212
  };
188
213
  }
189
214
  /**
190
- * Wait for an ACK/ERROR from the counterparty for a given channel. Returns the
191
- * reply message (so callers can read a co-signature) or null on timeout.
215
+ * Send a proposal and wait for its ACK/ERROR reply.
216
+ *
217
+ * The message is resent with the SAME nonce when the peer reconnects (a
218
+ * proposal or its reply may have been lost when the old stream closed) and
219
+ * once mid-flight in case the reply was lost without the stream closing.
220
+ * Receivers are expected to deduplicate by `(type, channelId, nonce)` so a
221
+ * resend never consumes another WOTS leaf or yields a different signature.
192
222
  */
193
- function waitForPeerReply(peer, channelId, timeoutMs) {
223
+ function requestPeerReply(peer, msg, channelId, timeoutMs, types = ['ACK', 'ERROR']) {
194
224
  return new Promise((resolve) => {
195
225
  let done = false;
196
- let unsub;
226
+ let timer;
227
+ let fallback;
228
+ const unsubs = [];
197
229
  const finish = (reply) => {
198
230
  if (done)
199
231
  return;
200
232
  done = true;
201
- clearTimeout(timer);
202
- if (unsub)
233
+ if (timer)
234
+ clearTimeout(timer);
235
+ if (fallback)
236
+ clearTimeout(fallback);
237
+ for (const unsub of unsubs)
203
238
  unsub();
204
239
  resolve(reply);
205
240
  };
206
- unsub = peer.onMessage((msg) => {
207
- if (msg.channelId !== channelId)
241
+ const resend = (reason) => {
242
+ if (done)
208
243
  return;
209
- if (msg.type === 'ACK' || msg.type === 'ERROR')
210
- finish(msg);
211
- });
212
- const timer = setTimeout(() => finish(null), timeoutMs);
244
+ if (process.env.OMNIA_DEBUG) {
245
+ console.error('[peer] resend', msg.type, msg.nonce, reason, channelId);
246
+ }
247
+ void peer.sendMessage(msg).catch(() => { });
248
+ };
249
+ unsubs.push(peer.onMessage((reply) => {
250
+ if (reply.channelId !== channelId)
251
+ return;
252
+ if (msg.nonce !== undefined && reply.nonce !== undefined && reply.nonce !== msg.nonce)
253
+ return;
254
+ if (types.includes(reply.type))
255
+ finish(reply);
256
+ }));
257
+ if (peer.onReconnected) {
258
+ unsubs.push(peer.onReconnected(() => resend('reconnected')));
259
+ }
260
+ fallback = setTimeout(() => resend('no reply'), Math.min(10_000, Math.floor(timeoutMs / 2)));
261
+ timer = setTimeout(() => finish(null), timeoutMs);
262
+ void peer.sendMessage(msg).catch(() => { });
213
263
  });
214
264
  }
265
+ function signingIndicesParam(value) {
266
+ if (!value || typeof value !== 'object')
267
+ throw new Error('signingIndices is required');
268
+ const item = value;
269
+ const num = (key) => {
270
+ const parsed = Number(item[key]);
271
+ if (!Number.isInteger(parsed) || parsed < 0)
272
+ throw new Error(`signingIndices.${key} must be a non-negative integer`);
273
+ return parsed;
274
+ };
275
+ return { addressIndex: num('addressIndex'), l1: num('l1'), l2: num('l2') };
276
+ }
277
+ function hexBytes(value, label) {
278
+ if (typeof value !== 'string')
279
+ throw new Error(`${label} must be a hex string`);
280
+ return Buffer.from(value.replace(/^0x/i, ''), 'hex');
281
+ }
282
+ function registerPreparedPayMethods(methods, context) {
283
+ const preparePay = async (raw) => {
284
+ const params = paramsObject(raw);
285
+ const channelId = requiredString(params, 'channelId');
286
+ const amount = requiredBigInt(params, 'amount');
287
+ const localPartyId = context.localParticipant?.partyId;
288
+ const localPkd = context.localParticipant?.publicKeyDigest;
289
+ if (!localPartyId || !localPkd)
290
+ throw new Error('Local participant is not configured');
291
+ if (!context.preparedPay)
292
+ throw new Error('Prepared-pay store is not configured');
293
+ const channel = context.channels.get(channelId);
294
+ if (!channel)
295
+ throw new Error(`Channel ${channelId} not found`);
296
+ const remotePartyId = channel.parties.find((party) => party.partyId !== localPartyId)?.partyId;
297
+ if (!remotePartyId)
298
+ throw new Error('Channel counterparty not found');
299
+ if (amount <= 0n || (channel.balances[localPartyId] ?? 0n) < amount) {
300
+ throw new Error('Insufficient channel balance');
301
+ }
302
+ if ((context.custodyMode ?? 'self') === 'hosted') {
303
+ throw new Error('preparePay requires an explicit hosted custody disclosure context (not yet configured)');
304
+ }
305
+ return withOperation(context, params, async () => {
306
+ const preparedOperationId = operationId(params);
307
+ const unsigned = (0, omnia_1.buildStateUpdate)(channel, {
308
+ newSequence: channel.currentSequence + 1,
309
+ newBalances: {
310
+ ...channel.balances,
311
+ [localPartyId]: (channel.balances[localPartyId] ?? 0n) - amount,
312
+ [remotePartyId]: (channel.balances[remotePartyId] ?? 0n) + amount,
313
+ },
314
+ }, { publicKeyDigest: localPkd });
315
+ const expiresAt = Date.now() + 120_000;
316
+ const base = {
317
+ domain: 'channel',
318
+ signers: [localPkd],
319
+ custody: (0, omnia_1.makeSelfCustody)(),
320
+ expiresAt,
321
+ };
322
+ const intent = {
323
+ ...base,
324
+ requestId: `${preparedOperationId}:intent`,
325
+ kind: 'intent',
326
+ digest: unsigned.updateDigest,
327
+ context: { summary: `Authorize payment of ${amount.toString()} on channel ${channelId}` },
328
+ };
329
+ const update = {
330
+ ...base,
331
+ requestId: `${preparedOperationId}:update`,
332
+ kind: 'chain',
333
+ digest: unsigned.updateDigest,
334
+ context: { summary: 'Sign the channel update transaction' },
335
+ };
336
+ const settlement = {
337
+ ...base,
338
+ requestId: `${preparedOperationId}:settlement`,
339
+ kind: 'chain',
340
+ digest: unsigned.settlementDigest,
341
+ context: { summary: 'Sign the settlement close package' },
342
+ };
343
+ for (const [key, entry] of context.preparedPay) {
344
+ if (entry.expiresAt <= Date.now())
345
+ context.preparedPay.delete(key);
346
+ }
347
+ context.preparedPay.set(preparedOperationId, { channelId, unsigned, intent, update, settlement, expiresAt });
348
+ return {
349
+ success: true,
350
+ status: 'signature-required',
351
+ operationId: preparedOperationId,
352
+ channelId,
353
+ requests: { intent, update, settlement },
354
+ expiresAt,
355
+ };
356
+ });
357
+ };
358
+ methods.set('totem_omniaPreparePay', preparePay);
359
+ methods.set('omnia/preparePay', preparePay);
360
+ const prepareChannelUpdate = async (raw) => {
361
+ const params = paramsObject(raw);
362
+ const channelId = requiredString(params, 'channelId');
363
+ const localPartyId = context.localParticipant?.partyId;
364
+ const localPkd = context.localParticipant?.publicKeyDigest;
365
+ if (!localPartyId || !localPkd)
366
+ throw new Error('Local participant is not configured');
367
+ if (!context.preparedPay)
368
+ throw new Error('Prepared-pay store is not configured');
369
+ const channel = context.channels.get(channelId);
370
+ if (!channel)
371
+ throw new Error(`Channel ${channelId} not found`);
372
+ if ((context.custodyMode ?? 'self') === 'hosted') {
373
+ throw new Error('prepareChannelUpdate requires an explicit hosted custody disclosure context (not yet configured)');
374
+ }
375
+ const rawBalances = params.newBalances;
376
+ const newBalances = rawBalances && typeof rawBalances === 'object'
377
+ ? Object.fromEntries(Object.entries(rawBalances).map(([partyId, value]) => [partyId, BigInt(String(value))]))
378
+ : channel.balances;
379
+ const programTransition = params.programTransition;
380
+ return withOperation(context, params, async () => {
381
+ const preparedOperationId = operationId(params);
382
+ const unsigned = (0, omnia_1.buildStateUpdate)(channel, { newSequence: channel.currentSequence + 1, newBalances, programTransition }, { publicKeyDigest: localPkd });
383
+ const expiresAt = Date.now() + 120_000;
384
+ const base = {
385
+ domain: 'channel',
386
+ signers: [localPkd],
387
+ custody: (0, omnia_1.makeSelfCustody)(),
388
+ expiresAt,
389
+ };
390
+ const summary = typeof params.memo === 'string' && params.memo ? params.memo : 'Channel state update';
391
+ const intent = {
392
+ ...base,
393
+ requestId: `${preparedOperationId}:intent`,
394
+ kind: 'intent',
395
+ digest: unsigned.updateDigest,
396
+ context: { summary: `Authorize ${summary}` },
397
+ };
398
+ const update = {
399
+ ...base,
400
+ requestId: `${preparedOperationId}:update`,
401
+ kind: 'chain',
402
+ digest: unsigned.updateDigest,
403
+ context: { summary: 'Sign the channel update transaction' },
404
+ };
405
+ const settlement = {
406
+ ...base,
407
+ requestId: `${preparedOperationId}:settlement`,
408
+ kind: 'chain',
409
+ digest: unsigned.settlementDigest,
410
+ context: { summary: 'Sign the settlement close package' },
411
+ };
412
+ for (const [key, entry] of context.preparedPay) {
413
+ if (entry.expiresAt <= Date.now())
414
+ context.preparedPay.delete(key);
415
+ }
416
+ context.preparedPay.set(preparedOperationId, { channelId, unsigned, intent, update, settlement, expiresAt });
417
+ return {
418
+ success: true,
419
+ status: 'signature-required',
420
+ operationId: preparedOperationId,
421
+ channelId,
422
+ requests: { intent, update, settlement },
423
+ expiresAt,
424
+ };
425
+ });
426
+ };
427
+ methods.set('totem_omniaPrepareChannelUpdate', prepareChannelUpdate);
428
+ methods.set('omnia/prepareChannelUpdate', prepareChannelUpdate);
429
+ const submitPay = async (raw) => {
430
+ const params = paramsObject(raw);
431
+ const preparedOperationId = requiredString(params, 'preparedOperationId');
432
+ const localPkd = context.localParticipant?.publicKeyDigest;
433
+ if (!localPkd)
434
+ throw new Error('Local participant is not configured');
435
+ if (!context.preparedPay)
436
+ throw new Error('Prepared-pay store is not configured');
437
+ return withOperation(context, params, async () => {
438
+ const entry = context.preparedPay.get(preparedOperationId);
439
+ if (!entry)
440
+ throw new Error(`Prepared payment ${preparedOperationId} not found`);
441
+ if (entry.expiresAt <= Date.now()) {
442
+ context.preparedPay.delete(preparedOperationId);
443
+ throw new Error(`Prepared payment ${preparedOperationId} has expired`);
444
+ }
445
+ const intentSignature = hexBytes(params.intentSignature, 'intentSignature');
446
+ const updateSignature = hexBytes(params.updateSignature, 'updateSignature');
447
+ const settlementSignature = hexBytes(params.settlementSignature, 'settlementSignature');
448
+ const updateIndices = signingIndicesParam(params.updateIndices);
449
+ const settlementIndices = signingIndicesParam(params.settlementIndices);
450
+ if (!(0, omnia_1.verifyChannelSignature)(intentSignature, (0, omnia_1.signRequestPayload)(entry.intent), localPkd)) {
451
+ throw new Error('intent signature does not verify');
452
+ }
453
+ if (!(0, omnia_1.verifyChannelSignature)(updateSignature, entry.unsigned.updateDigest, localPkd)) {
454
+ throw new Error('update signature does not verify');
455
+ }
456
+ if (!(0, omnia_1.verifyChannelSignature)(settlementSignature, entry.unsigned.settlementDigest, localPkd)) {
457
+ throw new Error('settlement signature does not verify');
458
+ }
459
+ const state = (0, omnia_1.finalizeStateUpdateWithIndices)(entry.unsigned, updateSignature, updateIndices, settlementSignature, settlementIndices);
460
+ const channel = entry.unsigned.channel;
461
+ const updatedChannel = {
462
+ ...channel,
463
+ balances: state.balances,
464
+ currentSequence: state.sequence,
465
+ pendingProposal: { sequence: state.sequence, payloadHash: entry.unsigned.payloadHash },
466
+ stateLog: [
467
+ ...channel.stateLog,
468
+ {
469
+ sequence: state.sequence,
470
+ timestamp: Date.now(),
471
+ balances: { ...state.balances },
472
+ htlcCount: entry.unsigned.pendingHTLCs.length,
473
+ event: 'update',
474
+ },
475
+ ],
476
+ updatedAt: Date.now(),
477
+ };
478
+ context.channels.set(entry.channelId, updatedChannel);
479
+ const localPartyId = context.localParticipant.partyId;
480
+ const remote = channel.parties.find((party) => party.partyId !== localPartyId);
481
+ if (!remote)
482
+ throw new Error('Channel counterparty not found');
483
+ if (context.swarm) {
484
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, entry.channelId, remote.transportPubkey);
485
+ await peer.sendMessage({
486
+ type: 'STATE_UPDATE',
487
+ channelId: entry.channelId,
488
+ nonce: Date.now(),
489
+ payload: state,
490
+ });
491
+ }
492
+ context.preparedPay.delete(preparedOperationId);
493
+ return {
494
+ success: true,
495
+ channelId: entry.channelId,
496
+ sequence: state.sequence,
497
+ localBalance: state.balances[localPartyId].toString(),
498
+ remoteBalance: state.balances[remote.partyId].toString(),
499
+ };
500
+ });
501
+ };
502
+ methods.set('totem_omniaSubmitPay', submitPay);
503
+ methods.set('omnia/submitPay', submitPay);
504
+ methods.set('totem_omniaSubmitChannelUpdate', submitPay);
505
+ methods.set('omnia/submitChannelUpdate', submitPay);
506
+ const prepareSettle = async (raw) => {
507
+ const params = paramsObject(raw);
508
+ const channelId = requiredString(params, 'channelId');
509
+ const localPartyId = context.localParticipant?.partyId;
510
+ const localPkd = context.localParticipant?.publicKeyDigest;
511
+ if (!localPartyId || !localPkd)
512
+ throw new Error('Local participant is not configured');
513
+ if (!context.preparedSettle)
514
+ throw new Error('Prepared-settle store is not configured');
515
+ if (!context.swarm)
516
+ throw new Error('prepareSettle requires swarm connectivity');
517
+ const channel = context.channels.get(channelId);
518
+ if (!channel)
519
+ throw new Error(`Channel ${channelId} not found`);
520
+ if (!['active', 'closing_mutual'].includes(channel.status)) {
521
+ throw new Error(`Channel ${channelId} is not settleable (${channel.status})`);
522
+ }
523
+ const closing = (0, omnia_1.markChannelClosing)(channel, 'mutual');
524
+ const partyAddresses = Object.fromEntries(closing.parties.map((party) => [party.partyId, party.settlementAddress ?? party.publicKeyDigest]));
525
+ const remote = closing.parties.find((party) => party.partyId !== localPartyId);
526
+ if (!remote)
527
+ throw new Error('Channel counterparty not found');
528
+ const request = (0, omnia_1.buildSettlementSigningRequest)(closing, { partyAddresses });
529
+ return withOperation(context, params, async () => {
530
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, channelId, remote.transportPubkey);
531
+ const reply = await requestPeerReply(peer, {
532
+ type: 'SETTLEMENT_PROPOSAL',
533
+ channelId,
534
+ nonce: Date.now(),
535
+ payload: { signRequest: true, ...request },
536
+ }, channelId, 30_000);
537
+ const payload = reply?.payload;
538
+ if (reply?.type !== 'ACK' || typeof payload?.signature !== 'string') {
539
+ throw new Error(`Counterparty did not co-sign the settlement (${reply?.type ?? 'timeout'})`);
540
+ }
541
+ const counterpartySignature = {
542
+ partyId: payload.partyId ?? remote.partyId,
543
+ signature: Buffer.from(payload.signature.replace(/^0x/i, ''), 'hex'),
544
+ };
545
+ const preparedOperationId = operationId(params);
546
+ const expiresAt = Date.now() + 120_000;
547
+ const digest = Buffer.from(request.digestHex.replace(/^0x/i, ''), 'hex');
548
+ const base = {
549
+ domain: 'channel',
550
+ signers: [localPkd],
551
+ custody: (0, omnia_1.makeSelfCustody)(),
552
+ expiresAt,
553
+ };
554
+ const intent = {
555
+ ...base,
556
+ requestId: `${preparedOperationId}:intent`,
557
+ kind: 'intent',
558
+ digest,
559
+ context: { summary: 'Authorize cooperative settlement' },
560
+ };
561
+ const settlement = {
562
+ ...base,
563
+ requestId: `${preparedOperationId}:settlement`,
564
+ kind: 'chain',
565
+ digest,
566
+ context: { summary: 'Sign the cooperative settlement transaction' },
567
+ };
568
+ for (const [key, entry] of context.preparedSettle) {
569
+ if (entry.expiresAt <= Date.now())
570
+ context.preparedSettle.delete(key);
571
+ }
572
+ context.preparedSettle.set(preparedOperationId, {
573
+ channelId,
574
+ closing,
575
+ request,
576
+ counterpartySignature,
577
+ intent,
578
+ settlement,
579
+ expiresAt,
580
+ });
581
+ return {
582
+ success: true,
583
+ status: 'signature-required',
584
+ operationId: preparedOperationId,
585
+ channelId,
586
+ requests: { intent, settlement },
587
+ expiresAt,
588
+ };
589
+ });
590
+ };
591
+ methods.set('totem_omniaPrepareSettle', prepareSettle);
592
+ methods.set('omnia/prepareSettle', prepareSettle);
593
+ const submitSettle = async (raw) => {
594
+ const params = paramsObject(raw);
595
+ const preparedOperationId = requiredString(params, 'preparedOperationId');
596
+ const localPkd = context.localParticipant?.publicKeyDigest;
597
+ if (!localPkd)
598
+ throw new Error('Local participant is not configured');
599
+ if (!context.preparedSettle)
600
+ throw new Error('Prepared-settle store is not configured');
601
+ return withOperation(context, params, async () => {
602
+ const entry = context.preparedSettle.get(preparedOperationId);
603
+ if (!entry)
604
+ throw new Error(`Prepared settlement ${preparedOperationId} not found`);
605
+ if (entry.expiresAt <= Date.now()) {
606
+ context.preparedSettle.delete(preparedOperationId);
607
+ throw new Error(`Prepared settlement ${preparedOperationId} has expired`);
608
+ }
609
+ const intentSignature = hexBytes(params.intentSignature, 'intentSignature');
610
+ const settlementSignature = hexBytes(params.settlementSignature, 'settlementSignature');
611
+ const digest = Buffer.from(entry.request.digestHex.replace(/^0x/i, ''), 'hex');
612
+ if (!(0, omnia_1.verifyChannelSignature)(intentSignature, (0, omnia_1.signRequestPayload)(entry.intent), localPkd)) {
613
+ throw new Error('intent signature does not verify');
614
+ }
615
+ if (!(0, omnia_1.verifyChannelSignature)(settlementSignature, digest, localPkd)) {
616
+ throw new Error('settlement signature does not verify');
617
+ }
618
+ if (!context.chainProvider)
619
+ throw new Error('Settlement requires a chain provider');
620
+ const rawProofs = params.broadcastProofs;
621
+ const broadcastProofs = rawProofs && typeof rawProofs === 'object'
622
+ && Array.isArray(rawProofs.coinProofs)
623
+ && Array.isArray(rawProofs.scriptProofs)
624
+ ? {
625
+ coinProofs: rawProofs.coinProofs.map((hex) => Buffer.from(String(hex).replace(/^0x/i, ''), 'hex')),
626
+ scriptProofs: rawProofs.scriptProofs.map((hex) => Buffer.from(String(hex).replace(/^0x/i, ''), 'hex')),
627
+ }
628
+ : undefined;
629
+ const settlementIndices = params.settlementIndices
630
+ ? signingIndicesParam(params.settlementIndices)
631
+ : undefined;
632
+ const settlement = await (0, omnia_1.proposeSettlement)(entry.closing, context.leaseProvider, {
633
+ chainProvider: context.chainProvider,
634
+ broadcastProofs,
635
+ partyAddresses: entry.request.partyAddresses,
636
+ settlementTxHex: entry.request.settlementTxHex,
637
+ counterpartySignatures: [entry.counterpartySignature],
638
+ localSignature: settlementSignature,
639
+ localIndices: settlementIndices,
640
+ localPartyId: context.localParticipant.partyId,
641
+ });
642
+ context.channels.set(entry.channelId, (0, omnia_1.markChannelClosed)(entry.closing));
643
+ context.preparedSettle.delete(preparedOperationId);
644
+ return {
645
+ success: true,
646
+ channelId: entry.channelId,
647
+ settlementTxId: settlement.settlementPayload.txpowId,
648
+ finalBalances: Object.fromEntries(Object.entries(settlement.settlementPayload.balances).map(([key, value]) => [key, value.toString()])),
649
+ };
650
+ });
651
+ };
652
+ methods.set('totem_omniaSubmitSettle', submitSettle);
653
+ methods.set('omnia/submitSettle', submitSettle);
654
+ const prepareOpenChannel = async (raw) => {
655
+ const params = paramsObject(raw);
656
+ const localParticipant = context.localParticipant;
657
+ const localPkd = localParticipant?.publicKeyDigest;
658
+ if (!localParticipant || !localPkd)
659
+ throw new Error('Local participant is not configured');
660
+ if (!context.preparedOpen)
661
+ throw new Error('Prepared-open store is not configured');
662
+ if (!context.chainProvider)
663
+ throw new Error('prepareOpenChannel requires a chain provider');
664
+ const remote = {
665
+ partyId: requiredString(params, 'remotePartyId'),
666
+ publicKeyDigest: requiredString(params, 'remotePublicKeyDigest'),
667
+ addressIndex: typeof params.remoteAddressIndex === 'number' ? params.remoteAddressIndex : 0,
668
+ settlementAddress: typeof params.remoteSettlementAddress === 'string' ? params.remoteSettlementAddress : undefined,
669
+ transportPubkey: typeof params.remoteTransportPubkey === 'string' ? params.remoteTransportPubkey : undefined,
670
+ };
671
+ const createParams = {
672
+ localParty: {
673
+ ...localParticipant,
674
+ transportPubkey: localParticipant.transportPubkey ?? context.transportPubkey,
675
+ },
676
+ remoteParty: remote,
677
+ localAmount: requiredBigInt(params, 'localAmount'),
678
+ remoteAmount: requiredBigInt(params, 'remoteAmount'),
679
+ tokenId: typeof params.tokenId === 'string' ? params.tokenId : undefined,
680
+ fundingCoinId: requiredString(params, 'fundingCoinId'),
681
+ contestDelayBlocks: typeof params.contestDelayBlocks === 'number' ? params.contestDelayBlocks : undefined,
682
+ fundingScript: typeof params.fundingScript === 'string' ? params.fundingScript : undefined,
683
+ };
684
+ return withOperation(context, params, async () => {
685
+ const plan = await (0, omnia_1.buildChannelOpening)(createParams, context.chainProvider);
686
+ const preparedOperationId = operationId(params);
687
+ const expiresAt = Date.now() + 300_000;
688
+ const base = {
689
+ domain: 'channel',
690
+ signers: [localPkd],
691
+ custody: (0, omnia_1.makeSelfCustody)(),
692
+ expiresAt,
693
+ };
694
+ const summary = `Open channel ${plan.channel.channelId} (${plan.channel.totalValue} units)`;
695
+ const intent = {
696
+ ...base,
697
+ requestId: `${preparedOperationId}:intent`,
698
+ kind: 'intent',
699
+ digest: plan.fundingDigest,
700
+ context: {
701
+ summary: `Authorize ${summary}`,
702
+ amount: plan.channel.totalValue.toString(),
703
+ asset: plan.channel.tokenId,
704
+ terms: `Counterparty ${remote.partyId}; channel ${plan.channel.channelId}`,
705
+ },
706
+ };
707
+ const funding = {
708
+ ...base,
709
+ requestId: `${preparedOperationId}:funding`,
710
+ kind: 'chain',
711
+ digest: plan.fundingDigest,
712
+ context: { summary: 'Sign the channel funding transaction' },
713
+ };
714
+ for (const [key, entry] of context.preparedOpen) {
715
+ if (entry.expiresAt <= Date.now())
716
+ context.preparedOpen.delete(key);
717
+ }
718
+ context.preparedOpen.set(preparedOperationId, {
719
+ plan,
720
+ fundingScript: createParams.fundingScript,
721
+ intent,
722
+ funding,
723
+ expiresAt,
724
+ });
725
+ return {
726
+ success: true,
727
+ status: 'signature-required',
728
+ operationId: preparedOperationId,
729
+ channelId: plan.channel.channelId,
730
+ fundingAddress: plan.fundingAddress,
731
+ fundingCoinId: plan.outputCoinId,
732
+ inputCoinId: plan.inputCoinId,
733
+ totalValue: plan.channel.totalValue.toString(),
734
+ requests: { intent, funding },
735
+ expiresAt,
736
+ };
737
+ });
738
+ };
739
+ methods.set('totem_omniaPrepareOpenChannel', prepareOpenChannel);
740
+ methods.set('omnia/prepareOpenChannel', prepareOpenChannel);
741
+ const submitOpenChannel = async (raw) => {
742
+ const params = paramsObject(raw);
743
+ const preparedOperationId = requiredString(params, 'preparedOperationId');
744
+ const localPkd = context.localParticipant?.publicKeyDigest;
745
+ if (!localPkd)
746
+ throw new Error('Local participant is not configured');
747
+ if (!context.preparedOpen)
748
+ throw new Error('Prepared-open store is not configured');
749
+ if (!context.chainProvider)
750
+ throw new Error('submitOpenChannel requires a chain provider');
751
+ return withOperation(context, params, async () => {
752
+ const entry = context.preparedOpen.get(preparedOperationId);
753
+ if (!entry)
754
+ throw new Error(`Prepared channel opening ${preparedOperationId} not found`);
755
+ if (entry.expiresAt <= Date.now()) {
756
+ context.preparedOpen.delete(preparedOperationId);
757
+ throw new Error(`Prepared channel opening ${preparedOperationId} has expired`);
758
+ }
759
+ const intentSignature = hexBytes(params.intentSignature, 'intentSignature');
760
+ const fundingSignature = hexBytes(params.fundingSignature, 'fundingSignature');
761
+ if (!(0, omnia_1.verifyChannelSignature)(intentSignature, (0, omnia_1.signRequestPayload)(entry.intent), localPkd)) {
762
+ throw new Error('intent signature does not verify');
763
+ }
764
+ if (!(0, omnia_1.verifyChannelSignature)(fundingSignature, entry.plan.fundingDigest, localPkd)) {
765
+ throw new Error('funding signature does not verify');
766
+ }
767
+ const toBytes = (value) => Array.isArray(value) ? value.map((item) => Buffer.from(String(item).replace(/^0x/i, ''), 'hex')) : [];
768
+ const fundingWitnessHex = typeof params.fundingWitnessHex === 'string' ? params.fundingWitnessHex : undefined;
769
+ let finalizeOpts;
770
+ if (fundingWitnessHex) {
771
+ finalizeOpts = { fundingWitnessBytes: Buffer.from(fundingWitnessHex.replace(/^0x/i, ''), 'hex') };
772
+ }
773
+ else {
774
+ finalizeOpts = {
775
+ fundingSignature,
776
+ fundingProofs: {
777
+ coinProofs: toBytes(params.fundingCoinProofsHex),
778
+ scriptProofs: toBytes(params.fundingScriptProofsHex),
779
+ },
780
+ fundingScript: typeof params.fundingScript === 'string'
781
+ ? params.fundingScript
782
+ : entry.fundingScript,
783
+ };
784
+ }
785
+ const { channel, proposal, fundingTxId } = await (0, omnia_1.finalizeChannelOpening)(entry.plan, finalizeOpts, context.chainProvider);
786
+ context.channels.set(channel.channelId, { ...channel, localSigner: context.signer });
787
+ let acknowledged = true;
788
+ if (context.swarm) {
789
+ const remote = channel.parties.find((party) => party.partyId !== context.localParticipant.partyId);
790
+ if (remote) {
791
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, channel.channelId, remote.transportPubkey);
792
+ const reply = await requestPeerReply(peer, { type: 'CHANNEL_PROPOSAL', channelId: channel.channelId, nonce: Date.now(), payload: proposal }, channel.channelId, 20_000);
793
+ acknowledged = reply?.type === 'ACK';
794
+ }
795
+ }
796
+ context.preparedOpen.delete(preparedOperationId);
797
+ return { success: true, channelId: channel.channelId, fundingTxId, acknowledged };
798
+ });
799
+ };
800
+ methods.set('totem_omniaSubmitOpenChannel', submitOpenChannel);
801
+ methods.set('omnia/submitOpenChannel', submitOpenChannel);
802
+ const prepareSplice = (type) => async (raw) => {
803
+ const params = paramsObject(raw);
804
+ if (!context.preparedSplice || !context.spliceAcceptances) {
805
+ throw new Error('Splice manager is not configured');
806
+ }
807
+ if (!context.swarm)
808
+ throw new Error('Splice preparation requires swarm connectivity');
809
+ const channelId = requiredString(params, 'channelId');
810
+ const channel = context.channels.get(channelId);
811
+ if (!channel)
812
+ throw new Error(`Channel ${channelId} not found`);
813
+ const localPartyId = context.localParticipant?.partyId;
814
+ const localPkd = context.localParticipant?.publicKeyDigest;
815
+ if (!localPartyId || !localPkd)
816
+ throw new Error('Local participant is not configured');
817
+ if (channel.status !== 'active')
818
+ throw new Error(`Channel ${channelId} is not active (${channel.status})`);
819
+ const pending = channel.pendingHTLCs.filter((h) => h.status === 'pending');
820
+ if (pending.length > 0)
821
+ throw new Error(`Cannot splice with ${pending.length} pending HTLC(s)`);
822
+ const remote = channel.parties.find((party) => party.partyId !== localPartyId);
823
+ if (!remote)
824
+ throw new Error('Channel counterparty not found');
825
+ const nextSequence = channel.currentSequence + 1;
826
+ const quiesceUnsigned = (0, omnia_1.buildStateUpdate)(channel, { newSequence: nextSequence, newBalances: { ...channel.balances } }, { publicKeyDigest: localPkd });
827
+ const quiescedChannel = {
828
+ ...channel,
829
+ currentSequence: nextSequence,
830
+ balances: { ...channel.balances },
831
+ pendingHTLCs: [],
832
+ latestState: null,
833
+ status: 'quiesced',
834
+ quiesceSignedState: quiesceUnsigned.unsignedClosePackage,
835
+ updatedAt: Date.now(),
836
+ };
837
+ const parsedBalances = params.newBalances && typeof params.newBalances === 'object'
838
+ ? Object.fromEntries(Object.entries(params.newBalances).map(([partyId, value]) => [partyId, BigInt(String(value))]))
839
+ : undefined;
840
+ const unsigned = type === 'in'
841
+ ? (0, omnia_splice_1.buildSpliceInProposal)(quiescedChannel, requiredString(params, 'additionalCoinId'), requiredBigInt(params, 'additionalAmount'), localPkd, parsedBalances)
842
+ : (0, omnia_splice_1.buildSpliceOutProposal)(quiescedChannel, requiredBigInt(params, 'withdrawAmount'), requiredString(params, 'withdrawAddress'), localPkd, parsedBalances);
843
+ return withOperation(context, params, async () => {
844
+ const placeholder = {
845
+ spliceId: unsigned.spliceId,
846
+ channelId: unsigned.channelId,
847
+ params: unsigned.params,
848
+ spliceTxHex: unsigned.spliceTxHex,
849
+ spliceTxDraft: unsigned.spliceTxDraft,
850
+ proposerPublicKeyDigest: localPkd,
851
+ proposerSignature: new Uint8Array(0),
852
+ proposerReservationId: '',
853
+ proposerSigningIndices: { addressIndex: 0, l1: 0, l2: 0 },
854
+ proposedAt: Date.now(),
855
+ };
856
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, channelId, remote.transportPubkey);
857
+ const reply = await requestPeerReply(peer, { type: 'SPLICE_PROPOSAL', channelId, nonce: Date.now(), payload: { proposal: placeholder } }, channelId, 30_000, ['SPLICE_ACCEPT', 'ERROR']);
858
+ const acceptance = reply?.payload?.acceptance;
859
+ if (reply?.type !== 'SPLICE_ACCEPT' || !acceptance) {
860
+ throw new Error(`Counterparty did not accept the splice (${reply?.type ?? 'timeout'})`);
861
+ }
862
+ context.spliceAcceptances.set(unsigned.spliceId, acceptance);
863
+ const preparedOperationId = operationId(params);
864
+ const expiresAt = Date.now() + 300_000;
865
+ const base = {
866
+ domain: 'splice',
867
+ signers: [localPkd],
868
+ custody: (0, omnia_1.makeSelfCustody)(),
869
+ expiresAt,
870
+ };
871
+ const quiesceIntent = {
872
+ ...base,
873
+ domain: 'channel',
874
+ requestId: `${preparedOperationId}:quiesce:intent`,
875
+ kind: 'intent',
876
+ digest: quiesceUnsigned.updateDigest,
877
+ context: { summary: `Authorize quiesce before ${type === 'in' ? 'splice-in' : 'splice-out'}` },
878
+ };
879
+ const quiesceUpdate = {
880
+ ...base,
881
+ domain: 'channel',
882
+ requestId: `${preparedOperationId}:quiesce:update`,
883
+ kind: 'chain',
884
+ digest: quiesceUnsigned.updateDigest,
885
+ context: { summary: 'Sign the quiesce channel update' },
886
+ };
887
+ const quiesceSettlement = {
888
+ ...base,
889
+ domain: 'channel',
890
+ requestId: `${preparedOperationId}:quiesce:settlement`,
891
+ kind: 'chain',
892
+ digest: quiesceUnsigned.settlementDigest,
893
+ context: { summary: 'Sign the quiesce settlement package' },
894
+ };
895
+ const intent = {
896
+ ...base,
897
+ requestId: `${preparedOperationId}:intent`,
898
+ kind: 'intent',
899
+ digest: unsigned.digest,
900
+ context: {
901
+ summary: `Authorize ${type === 'in' ? 'splice-in' : 'splice-out'} on ${channelId}`,
902
+ amount: unsigned.params.newTotalValue.toString(),
903
+ },
904
+ };
905
+ const splice = {
906
+ ...base,
907
+ requestId: `${preparedOperationId}:splice`,
908
+ kind: 'chain',
909
+ digest: unsigned.digest,
910
+ context: { summary: 'Sign the splice transaction' },
911
+ };
912
+ for (const [key, entry] of context.preparedSplice) {
913
+ if (entry.expiresAt <= Date.now())
914
+ context.preparedSplice.delete(key);
915
+ }
916
+ context.preparedSplice.set(preparedOperationId, {
917
+ channelId,
918
+ quiescedChannel,
919
+ quiesce: {
920
+ unsigned: quiesceUnsigned,
921
+ intent: quiesceIntent,
922
+ update: quiesceUpdate,
923
+ settlement: quiesceSettlement,
924
+ },
925
+ unsigned,
926
+ acceptance,
927
+ intent,
928
+ splice,
929
+ expiresAt,
930
+ });
931
+ return {
932
+ success: true,
933
+ status: 'signature-required',
934
+ operationId: preparedOperationId,
935
+ channelId,
936
+ spliceId: unsigned.spliceId,
937
+ spliceType: type,
938
+ requests: {
939
+ quiesce: { intent: quiesceIntent, update: quiesceUpdate, settlement: quiesceSettlement },
940
+ splice: { intent, chain: splice },
941
+ },
942
+ expiresAt,
943
+ };
944
+ });
945
+ };
946
+ methods.set('totem_omniaPrepareSpliceIn', prepareSplice('in'));
947
+ methods.set('omnia/prepareSpliceIn', prepareSplice('in'));
948
+ methods.set('totem_omniaPrepareSpliceOut', prepareSplice('out'));
949
+ methods.set('omnia/prepareSpliceOut', prepareSplice('out'));
950
+ const submitSplice = async (raw) => {
951
+ const params = paramsObject(raw);
952
+ const preparedOperationId = requiredString(params, 'preparedOperationId');
953
+ const localPkd = context.localParticipant?.publicKeyDigest;
954
+ if (!localPkd)
955
+ throw new Error('Local participant is not configured');
956
+ if (!context.preparedSplice)
957
+ throw new Error('Prepared-splice store is not configured');
958
+ return withOperation(context, params, async () => {
959
+ const entry = context.preparedSplice.get(preparedOperationId);
960
+ if (!entry)
961
+ throw new Error(`Prepared splice ${preparedOperationId} not found`);
962
+ if (entry.expiresAt <= Date.now()) {
963
+ context.preparedSplice.delete(preparedOperationId);
964
+ throw new Error(`Prepared splice ${preparedOperationId} has expired`);
965
+ }
966
+ const quiesceIntentSignature = hexBytes(params.quiesceIntentSignature, 'quiesceIntentSignature');
967
+ const quiesceUpdateSignature = hexBytes(params.quiesceUpdateSignature, 'quiesceUpdateSignature');
968
+ const quiesceSettlementSignature = hexBytes(params.quiesceSettlementSignature, 'quiesceSettlementSignature');
969
+ const spliceIntentSignature = hexBytes(params.spliceIntentSignature, 'spliceIntentSignature');
970
+ const spliceSignature = hexBytes(params.spliceSignature, 'spliceSignature');
971
+ const updateIndices = signingIndicesParam(params.quiesceUpdateIndices);
972
+ const settlementIndices = signingIndicesParam(params.quiesceSettlementIndices);
973
+ const spliceIndices = params.spliceIndices
974
+ ? signingIndicesParam(params.spliceIndices)
975
+ : { addressIndex: 0, l1: 0, l2: 0 };
976
+ if (!(0, omnia_1.verifyChannelSignature)(quiesceIntentSignature, (0, omnia_1.signRequestPayload)(entry.quiesce.intent), localPkd)) {
977
+ throw new Error('quiesce intent signature does not verify');
978
+ }
979
+ if (!(0, omnia_1.verifyChannelSignature)(quiesceUpdateSignature, entry.quiesce.unsigned.updateDigest, localPkd)) {
980
+ throw new Error('quiesce update signature does not verify');
981
+ }
982
+ if (!(0, omnia_1.verifyChannelSignature)(quiesceSettlementSignature, entry.quiesce.unsigned.settlementDigest, localPkd)) {
983
+ throw new Error('quiesce settlement signature does not verify');
984
+ }
985
+ if (!(0, omnia_1.verifyChannelSignature)(spliceIntentSignature, (0, omnia_1.signRequestPayload)(entry.intent), localPkd)) {
986
+ throw new Error('splice intent signature does not verify');
987
+ }
988
+ if (!(0, omnia_1.verifyChannelSignature)(spliceSignature, entry.unsigned.digest, localPkd)) {
989
+ throw new Error('splice signature does not verify');
990
+ }
991
+ if (!context.chainProvider?.broadcastTxPoW) {
992
+ throw new Error('Splice requires a chain provider with broadcastTxPoW');
993
+ }
994
+ const quiesceState = (0, omnia_1.finalizeStateUpdateWithIndices)(entry.quiesce.unsigned, quiesceUpdateSignature, updateIndices, quiesceSettlementSignature, settlementIndices);
995
+ const quiescedChannel = {
996
+ ...entry.quiescedChannel,
997
+ balances: quiesceState.balances,
998
+ currentSequence: quiesceState.sequence,
999
+ pendingHTLCs: [],
1000
+ latestState: quiesceState,
1001
+ quiesceSignedState: quiesceState,
1002
+ status: 'quiesced',
1003
+ updatedAt: Date.now(),
1004
+ };
1005
+ const { digest: _digest, ...unsignedBase } = entry.unsigned;
1006
+ const proposal = {
1007
+ ...unsignedBase,
1008
+ proposerSignature: spliceSignature,
1009
+ proposerReservationId: '',
1010
+ proposerSigningIndices: spliceIndices,
1011
+ proposedAt: Date.now(),
1012
+ };
1013
+ const spliced = await (0, omnia_splice_1.finalizeSplice)(quiescedChannel, proposal, entry.acceptance, {
1014
+ broadcast: (txHex) => context.chainProvider.broadcastTxPoW(txHex),
1015
+ });
1016
+ context.channels.set(entry.channelId, spliced);
1017
+ if (context.swarm) {
1018
+ const remote = spliced.parties.find((party) => party.partyId !== context.localParticipant.partyId);
1019
+ if (remote) {
1020
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, entry.channelId, remote.transportPubkey);
1021
+ await peer.sendMessage({
1022
+ type: 'SPLICE_FINALIZED',
1023
+ channelId: entry.channelId,
1024
+ nonce: Date.now(),
1025
+ payload: {
1026
+ channel: spliced,
1027
+ txId: spliced.spliceFundingTxId,
1028
+ spliceId: entry.unsigned.spliceId,
1029
+ },
1030
+ });
1031
+ }
1032
+ }
1033
+ context.preparedSplice.delete(preparedOperationId);
1034
+ return {
1035
+ success: true,
1036
+ channelId: entry.channelId,
1037
+ spliceId: entry.unsigned.spliceId,
1038
+ spliceType: spliced.spliceType,
1039
+ totalValue: spliced.totalValue.toString(),
1040
+ balances: Object.fromEntries(Object.entries(spliced.balances).map(([partyId, amount]) => [partyId, amount.toString()])),
1041
+ fundingCoinId: spliced.spliceFundingCoinId,
1042
+ };
1043
+ });
1044
+ };
1045
+ methods.set('totem_omniaSubmitSplice', submitSplice);
1046
+ methods.set('omnia/submitSplice', submitSplice);
1047
+ const prepareFactory = async (raw) => {
1048
+ const params = paramsObject(raw);
1049
+ const localPkd = context.localParticipant?.publicKeyDigest;
1050
+ if (!localPkd)
1051
+ throw new Error('Local participant is not configured');
1052
+ if (!context.preparedFactory)
1053
+ throw new Error('Prepared-factory store is not configured');
1054
+ const participantsRaw = params.participants;
1055
+ if (!Array.isArray(participantsRaw) || participantsRaw.length < 2) {
1056
+ throw new Error('participants must contain at least two factory participants');
1057
+ }
1058
+ const participants = participantsRaw.map((value) => {
1059
+ if (!value || typeof value !== 'object')
1060
+ throw new Error('Invalid factory participant');
1061
+ const item = value;
1062
+ return {
1063
+ partyId: requiredString(item, 'partyId'),
1064
+ publicKeyDigest: requiredString(item, 'publicKeyDigest'),
1065
+ addressIndex: Number(item.addressIndex ?? 0),
1066
+ contributionAmount: BigInt(requiredString(item, 'contributionAmount')),
1067
+ fundingCoinId: typeof item.fundingCoinId === 'string' ? item.fundingCoinId : undefined,
1068
+ settlementAddress: typeof item.settlementAddress === 'string' ? item.settlementAddress : undefined,
1069
+ transportPubkey: typeof item.transportPubkey === 'string' ? item.transportPubkey : undefined,
1070
+ };
1071
+ });
1072
+ const toBytes = (value) => typeof value === 'string' ? Buffer.from(value.replace(/^0x/i, ''), 'hex') : undefined;
1073
+ const fundingMaterials = Array.isArray(params.funding)
1074
+ ? params.funding.map((value) => {
1075
+ if (!value || typeof value !== 'object')
1076
+ throw new Error('Invalid factory funding material');
1077
+ const item = value;
1078
+ const coinProof = toBytes(item.coinProof);
1079
+ if (!coinProof)
1080
+ throw new Error('factory funding material requires coinProof');
1081
+ return {
1082
+ partyId: requiredString(item, 'partyId'),
1083
+ coinProof,
1084
+ ...(typeof item.coinAddress === 'string' ? { coinAddress: item.coinAddress } : {}),
1085
+ ...(typeof item.script === 'string' ? { script: item.script } : {}),
1086
+ ...(toBytes(item.scriptProof) ? { scriptProof: toBytes(item.scriptProof) } : {}),
1087
+ };
1088
+ })
1089
+ : undefined;
1090
+ const tokenId = typeof params.tokenId === 'string' ? params.tokenId : '0x00';
1091
+ const tokenScale = typeof params.tokenScale === 'number' ? params.tokenScale : 0;
1092
+ const remote = participants.find((participant) => participant.publicKeyDigest !== localPkd);
1093
+ return withOperation(context, params, async () => {
1094
+ const plan = await (0, omnia_factory_1.buildFactoryOpening)(participants, tokenId, tokenScale, context.chainProvider, fundingMaterials);
1095
+ let remoteFundingSignature;
1096
+ if (plan.onChain && context.swarm && remote) {
1097
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, plan.factory.factoryId, remote.transportPubkey);
1098
+ const reply = await requestPeerReply(peer, {
1099
+ type: 'FACTORY_FUNDING_REQUEST',
1100
+ channelId: plan.factory.factoryId,
1101
+ nonce: Date.now(),
1102
+ payload: { digest: '0x' + Buffer.from(plan.fundingDigest).toString('hex') },
1103
+ }, plan.factory.factoryId, 30_000, ['FACTORY_FUNDING_RESPONSE', 'ERROR']);
1104
+ const signature = reply?.payload?.signature;
1105
+ if (!signature)
1106
+ throw new Error(`Counterparty did not co-sign factory funding (${reply?.type ?? 'timeout'})`);
1107
+ remoteFundingSignature = Uint8Array.from(Buffer.from(signature.replace(/^0x/i, ''), 'hex'));
1108
+ }
1109
+ const preparedOperationId = operationId(params);
1110
+ const expiresAt = Date.now() + 300_000;
1111
+ const commitment = Buffer.from(plan.factory.pendingCommitment, 'hex');
1112
+ const base = {
1113
+ domain: 'factory',
1114
+ signers: [localPkd],
1115
+ custody: (0, omnia_1.makeSelfCustody)(),
1116
+ expiresAt,
1117
+ };
1118
+ const intent = {
1119
+ ...base,
1120
+ requestId: `${preparedOperationId}:intent`,
1121
+ kind: 'intent',
1122
+ digest: commitment,
1123
+ context: {
1124
+ summary: `Authorize factory opening (${plan.factory.totalValue} units)`,
1125
+ amount: plan.factory.totalValue.toString(),
1126
+ asset: plan.factory.tokenId,
1127
+ },
1128
+ };
1129
+ const commitmentRequest = {
1130
+ ...base,
1131
+ requestId: `${preparedOperationId}:commitment`,
1132
+ kind: 'chain',
1133
+ digest: commitment,
1134
+ context: { summary: 'Sign the factory funding/opening commitment' },
1135
+ };
1136
+ for (const [key, entry] of context.preparedFactory) {
1137
+ if (entry.expiresAt <= Date.now())
1138
+ context.preparedFactory.delete(key);
1139
+ }
1140
+ context.preparedFactory.set(preparedOperationId, {
1141
+ plan,
1142
+ remotePartyId: remote?.partyId,
1143
+ remoteFundingSignature,
1144
+ intent,
1145
+ commitment: commitmentRequest,
1146
+ expiresAt,
1147
+ });
1148
+ return {
1149
+ success: true,
1150
+ status: 'signature-required',
1151
+ operationId: preparedOperationId,
1152
+ factoryId: plan.factory.factoryId,
1153
+ onChain: plan.onChain,
1154
+ fundingCoinId: plan.fundingCoinId,
1155
+ totalValue: plan.factory.totalValue.toString(),
1156
+ requests: { intent, commitment: commitmentRequest },
1157
+ expiresAt,
1158
+ };
1159
+ });
1160
+ };
1161
+ methods.set('totem_omniaPrepareFactory', prepareFactory);
1162
+ methods.set('omnia/prepareFactory', prepareFactory);
1163
+ const submitFactory = async (raw) => {
1164
+ const params = paramsObject(raw);
1165
+ const preparedOperationId = requiredString(params, 'preparedOperationId');
1166
+ const localPkd = context.localParticipant?.publicKeyDigest;
1167
+ const localPartyId = context.localParticipant?.partyId;
1168
+ if (!localPkd || !localPartyId)
1169
+ throw new Error('Local participant is not configured');
1170
+ if (!context.preparedFactory)
1171
+ throw new Error('Prepared-factory store is not configured');
1172
+ return withOperation(context, params, async () => {
1173
+ const entry = context.preparedFactory.get(preparedOperationId);
1174
+ if (!entry)
1175
+ throw new Error(`Prepared factory ${preparedOperationId} not found`);
1176
+ if (entry.expiresAt <= Date.now()) {
1177
+ context.preparedFactory.delete(preparedOperationId);
1178
+ throw new Error(`Prepared factory ${preparedOperationId} has expired`);
1179
+ }
1180
+ const intentSignature = hexBytes(params.intentSignature, 'intentSignature');
1181
+ const commitmentSignature = hexBytes(params.commitmentSignature, 'commitmentSignature');
1182
+ const commitment = Buffer.from(entry.plan.factory.pendingCommitment, 'hex');
1183
+ if (!(0, omnia_1.verifyChannelSignature)(intentSignature, (0, omnia_1.signRequestPayload)(entry.intent), localPkd)) {
1184
+ throw new Error('intent signature does not verify');
1185
+ }
1186
+ if (!(0, omnia_1.verifyChannelSignature)(commitmentSignature, commitment, localPkd)) {
1187
+ throw new Error('factory commitment signature does not verify');
1188
+ }
1189
+ if (!context.chainProvider && entry.plan.onChain)
1190
+ throw new Error('Factory funding requires a chain provider');
1191
+ const fundingSignatures = { [localPartyId]: commitmentSignature };
1192
+ if (entry.remotePartyId && entry.remoteFundingSignature) {
1193
+ fundingSignatures[entry.remotePartyId] = entry.remoteFundingSignature;
1194
+ }
1195
+ let factory = await (0, omnia_factory_1.finalizeFactoryOpening)(entry.plan, {
1196
+ fundingSignatures: entry.plan.onChain ? fundingSignatures : undefined,
1197
+ openingSignatures: { [localPartyId]: commitmentSignature },
1198
+ }, context.chainProvider);
1199
+ context.factories?.set(factory.factoryId, factory);
1200
+ if (context.swarm && entry.remotePartyId) {
1201
+ const remote = factory.participants.find((participant) => participant.partyId === entry.remotePartyId);
1202
+ if (remote) {
1203
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, factory.factoryId, remote.transportPubkey);
1204
+ const reply = await requestPeerReply(peer, { type: 'FACTORY_PROPOSAL', channelId: factory.factoryId, nonce: Date.now(), payload: { factory } }, factory.factoryId, 30_000, ['FACTORY_ACCEPT', 'ERROR']);
1205
+ if (reply?.type !== 'FACTORY_ACCEPT') {
1206
+ throw new Error(`Counterparty did not accept the factory (${reply?.type ?? 'timeout'})`);
1207
+ }
1208
+ const accepted = reply.payload?.factory;
1209
+ if (!accepted)
1210
+ throw new Error('Factory accept reply missing factory');
1211
+ factory = accepted;
1212
+ context.factories?.set(factory.factoryId, factory);
1213
+ }
1214
+ }
1215
+ context.preparedFactory.delete(preparedOperationId);
1216
+ return { success: true, factoryId: factory.factoryId, status: factory.status, fundingTxId: factory.fundingTxId };
1217
+ });
1218
+ };
1219
+ methods.set('totem_omniaSubmitFactory', submitFactory);
1220
+ methods.set('omnia/submitFactory', submitFactory);
1221
+ const createVtxoPoolMethod = async (raw) => {
1222
+ const params = paramsObject(raw);
1223
+ if (!context.vtxoPools)
1224
+ throw new Error('VTXO stores are not configured');
1225
+ const localPkd = context.localParticipant?.publicKeyDigest;
1226
+ const operator = typeof params.operator === 'string' ? params.operator : localPkd;
1227
+ if (!operator)
1228
+ throw new Error('VTXO pool operator is required');
1229
+ const pool = (0, omnia_vtxo_1.createPool)({
1230
+ operator,
1231
+ tokenId: typeof params.tokenId === 'string' ? params.tokenId : '0x00',
1232
+ totalCapacity: requiredBigInt(params, 'totalCapacity'),
1233
+ nonce: requiredString(params, 'nonce'),
1234
+ ...(params.policy && typeof params.policy === 'object' ? { policy: params.policy } : {}),
1235
+ }, typeof params.now === 'number' ? params.now : Date.now());
1236
+ context.vtxoPools.set(pool.poolId, pool);
1237
+ return { success: true, pool: jsonSafe(pool) };
1238
+ };
1239
+ methods.set('totem_omniaCreateVtxoPool', createVtxoPoolMethod);
1240
+ methods.set('omnia/createVtxoPool', createVtxoPoolMethod);
1241
+ const mintVtxoMethod = async (raw) => {
1242
+ const params = paramsObject(raw);
1243
+ if (!context.vtxoPools || !context.vtxos || !context.vtxoReceipts)
1244
+ throw new Error('VTXO stores are not configured');
1245
+ const pool = context.vtxoPools.get(requiredString(params, 'poolId'));
1246
+ if (!pool)
1247
+ throw new Error(`VTXO pool ${String(params.poolId)} not found`);
1248
+ const minted = (0, omnia_vtxo_1.mintVtxo)(pool, {
1249
+ owner: requiredString(params, 'owner'),
1250
+ amount: requiredBigInt(params, 'amount'),
1251
+ nonce: requiredString(params, 'nonce'),
1252
+ expiresAt: typeof params.expiresAt === 'number' ? params.expiresAt : undefined,
1253
+ fundingProof: params.fundingProof,
1254
+ }, typeof params.now === 'number' ? params.now : undefined);
1255
+ context.vtxoPools.set(pool.poolId, minted.pool);
1256
+ context.vtxos.set(minted.vtxo.vtxoId, minted.vtxo);
1257
+ context.vtxoReceipts.set(minted.receipt.receiptId, minted.receipt);
1258
+ return { success: true, pool: jsonSafe(minted.pool), vtxo: jsonSafe(minted.vtxo), receipt: jsonSafe(minted.receipt) };
1259
+ };
1260
+ methods.set('totem_omniaMintVtxo', mintVtxoMethod);
1261
+ methods.set('omnia/mintVtxo', mintVtxoMethod);
1262
+ const transferVtxoMethod = async (raw) => {
1263
+ const params = paramsObject(raw);
1264
+ if (!context.vtxos || !context.vtxoReceipts)
1265
+ throw new Error('VTXO stores are not configured');
1266
+ const vtxo = context.vtxos.get(requiredString(params, 'vtxoId'));
1267
+ if (!vtxo)
1268
+ throw new Error(`VTXO ${String(params.vtxoId)} not found`);
1269
+ const result = (0, omnia_vtxo_1.transferVtxo)(vtxo, {
1270
+ recipient: requiredString(params, 'recipient'),
1271
+ amount: requiredBigInt(params, 'amount'),
1272
+ nonce: requiredString(params, 'nonce'),
1273
+ changeNonce: typeof params.changeNonce === 'string' ? params.changeNonce : undefined,
1274
+ }, typeof params.now === 'number' ? params.now : undefined);
1275
+ context.vtxos.set(result.input.vtxoId, result.input);
1276
+ context.vtxos.set(result.output.vtxoId, result.output);
1277
+ if (result.change)
1278
+ context.vtxos.set(result.change.vtxoId, result.change);
1279
+ context.vtxoReceipts.set(result.receipt.receiptId, result.receipt);
1280
+ return {
1281
+ success: true,
1282
+ input: jsonSafe(result.input),
1283
+ output: jsonSafe(result.output),
1284
+ change: result.change ? jsonSafe(result.change) : undefined,
1285
+ receipt: jsonSafe(result.receipt),
1286
+ };
1287
+ };
1288
+ methods.set('totem_omniaTransferVtxo', transferVtxoMethod);
1289
+ methods.set('omnia/transferVtxo', transferVtxoMethod);
1290
+ const refreshVtxoMethod = async (raw) => {
1291
+ const params = paramsObject(raw);
1292
+ if (!context.vtxos || !context.vtxoReceipts)
1293
+ throw new Error('VTXO stores are not configured');
1294
+ const vtxo = context.vtxos.get(requiredString(params, 'vtxoId'));
1295
+ if (!vtxo)
1296
+ throw new Error(`VTXO ${String(params.vtxoId)} not found`);
1297
+ const result = (0, omnia_vtxo_1.refreshVtxo)(vtxo, {
1298
+ newEpoch: Number(params.newEpoch),
1299
+ nonce: requiredString(params, 'nonce'),
1300
+ }, typeof params.now === 'number' ? params.now : undefined);
1301
+ context.vtxos.set(result.old.vtxoId, result.old);
1302
+ context.vtxos.set(result.refreshed.vtxoId, result.refreshed);
1303
+ if (context.vtxoPools) {
1304
+ const pool = context.vtxoPools.get(vtxo.poolId);
1305
+ if (pool)
1306
+ context.vtxoPools.set(pool.poolId, { ...pool, epoch: result.refreshed.epoch });
1307
+ }
1308
+ context.vtxoReceipts.set(result.receipt.receiptId, result.receipt);
1309
+ return { success: true, old: jsonSafe(result.old), refreshed: jsonSafe(result.refreshed), receipt: jsonSafe(result.receipt) };
1310
+ };
1311
+ methods.set('totem_omniaRefreshVtxo', refreshVtxoMethod);
1312
+ methods.set('omnia/refreshVtxo', refreshVtxoMethod);
1313
+ const initiateVtxoExitMethod = async (raw) => {
1314
+ const params = paramsObject(raw);
1315
+ if (!context.vtxos || !context.vtxoReceipts)
1316
+ throw new Error('VTXO stores are not configured');
1317
+ const vtxo = context.vtxos.get(requiredString(params, 'vtxoId'));
1318
+ if (!vtxo)
1319
+ throw new Error(`VTXO ${String(params.vtxoId)} not found`);
1320
+ const now = typeof params.now === 'number' ? params.now : undefined;
1321
+ const draft = (0, omnia_vtxo_1.createExitDraft)(vtxo, now);
1322
+ const exiting = (0, omnia_vtxo_1.markExiting)(vtxo, now);
1323
+ context.vtxos.set(exiting.vtxoId, exiting);
1324
+ context.vtxoReceipts.set(draft.receipt.receiptId, draft.receipt);
1325
+ return { success: true, vtxo: jsonSafe(exiting), draft: jsonSafe(draft.draft), receipt: jsonSafe(draft.receipt) };
1326
+ };
1327
+ methods.set('totem_omniaInitiateVtxoExit', initiateVtxoExitMethod);
1328
+ methods.set('omnia/initiateVtxoExit', initiateVtxoExitMethod);
1329
+ const completeVtxoExitMethod = async (raw) => {
1330
+ const params = paramsObject(raw);
1331
+ if (!context.vtxos)
1332
+ throw new Error('VTXO stores are not configured');
1333
+ const vtxo = context.vtxos.get(requiredString(params, 'vtxoId'));
1334
+ if (!vtxo)
1335
+ throw new Error(`VTXO ${String(params.vtxoId)} not found`);
1336
+ const exited = (0, omnia_vtxo_1.markExited)(vtxo, {
1337
+ txpowId: typeof params.txpowId === 'string' ? params.txpowId : undefined,
1338
+ now: typeof params.now === 'number' ? params.now : undefined,
1339
+ });
1340
+ context.vtxos.set(exited.vtxoId, exited);
1341
+ return { success: true, vtxo: jsonSafe(exited) };
1342
+ };
1343
+ methods.set('totem_omniaCompleteVtxoExit', completeVtxoExitMethod);
1344
+ methods.set('omnia/completeVtxoExit', completeVtxoExitMethod);
1345
+ const prepareVtxoReceipt = async (raw) => {
1346
+ const params = paramsObject(raw);
1347
+ if (!context.vtxoReceipts || !context.vtxos || !context.preparedVtxoReceipts) {
1348
+ throw new Error('VTXO stores are not configured');
1349
+ }
1350
+ const localPkd = context.localParticipant?.publicKeyDigest;
1351
+ if (!localPkd)
1352
+ throw new Error('Local participant is not configured');
1353
+ const receipt = context.vtxoReceipts.get(requiredString(params, 'receiptId'));
1354
+ if (!receipt)
1355
+ throw new Error(`VTXO receipt ${String(params.receiptId)} not found`);
1356
+ const outputs = receipt.outputIds.map((id) => {
1357
+ const vtxo = context.vtxos.get(id);
1358
+ if (!vtxo)
1359
+ throw new Error(`VTXO ${id} referenced by receipt is not stored`);
1360
+ return vtxo;
1361
+ });
1362
+ const root = typeof params.root === 'string' ? params.root : outputs[0]?.proof.root;
1363
+ const epoch = typeof params.epoch === 'number' ? params.epoch : receipt.epoch;
1364
+ if (!root)
1365
+ throw new Error('VTXO receipt root is required');
1366
+ const payload = (0, omnia_vtxo_1.buildVtxoReceiptPayload)(receipt, {
1367
+ outputs,
1368
+ root,
1369
+ epoch,
1370
+ previousEpoch: typeof params.previousEpoch === 'number' ? params.previousEpoch : undefined,
1371
+ inputsAmount: params.inputsAmount !== undefined ? BigInt(String(params.inputsAmount)) : undefined,
1372
+ });
1373
+ const request = {
1374
+ requestId: `vtxo-receipt-${receipt.receiptId}`,
1375
+ kind: 'chain',
1376
+ domain: 'vtxo',
1377
+ digest: payload,
1378
+ context: { summary: `Co-sign VTXO ${receipt.op} receipt` },
1379
+ signers: [localPkd],
1380
+ custody: (0, omnia_1.makeSelfCustody)(),
1381
+ expiresAt: Date.now() + 120_000,
1382
+ };
1383
+ const expiresAt = Date.now() + 120_000;
1384
+ context.preparedVtxoReceipts.set(receipt.receiptId, { receipt, payload, request, expiresAt });
1385
+ return { success: true, status: 'signature-required', receiptId: receipt.receiptId, requests: { receipt: request }, expiresAt };
1386
+ };
1387
+ methods.set('totem_omniaPrepareVtxoReceipt', prepareVtxoReceipt);
1388
+ methods.set('omnia/prepareVtxoReceipt', prepareVtxoReceipt);
1389
+ const submitVtxoReceipt = async (raw) => {
1390
+ const params = paramsObject(raw);
1391
+ const receiptId = requiredString(params, 'receiptId');
1392
+ const localPkd = context.localParticipant?.publicKeyDigest;
1393
+ if (!localPkd)
1394
+ throw new Error('Local participant is not configured');
1395
+ if (!context.preparedVtxoReceipts || !context.vtxoReceipts)
1396
+ throw new Error('VTXO stores are not configured');
1397
+ const entry = context.preparedVtxoReceipts.get(receiptId);
1398
+ if (!entry)
1399
+ throw new Error(`Prepared VTXO receipt ${receiptId} not found`);
1400
+ if (entry.expiresAt <= Date.now()) {
1401
+ context.preparedVtxoReceipts.delete(receiptId);
1402
+ throw new Error(`Prepared VTXO receipt ${receiptId} has expired`);
1403
+ }
1404
+ const signature = hexBytes(params.signature, 'signature');
1405
+ if (!(0, omnia_1.verifyChannelSignature)(signature, entry.payload, localPkd)) {
1406
+ throw new Error('VTXO receipt signature does not verify');
1407
+ }
1408
+ const signed = { ...entry.receipt, signature: '0x' + Buffer.from(signature).toString('hex') };
1409
+ context.vtxoReceipts.set(receiptId, signed);
1410
+ context.preparedVtxoReceipts.delete(receiptId);
1411
+ return { success: true, receipt: jsonSafe(signed) };
1412
+ };
1413
+ methods.set('totem_omniaSubmitVtxoReceipt', submitVtxoReceipt);
1414
+ methods.set('omnia/submitVtxoReceipt', submitVtxoReceipt);
1415
+ const createLiquidityPool = async (raw) => {
1416
+ const params = paramsObject(raw);
1417
+ if (!context.pools)
1418
+ throw new Error('Pool stores are not configured');
1419
+ const created = (0, omnia_pool_1.createOmniaPool)({
1420
+ poolId: requiredString(params, 'poolId'),
1421
+ operatorAddress: requiredString(params, 'operatorAddress'),
1422
+ tokenId: typeof params.tokenId === 'string' ? params.tokenId : '0x00',
1423
+ purpose: requiredString(params, 'purpose'),
1424
+ poolType: requiredString(params, 'poolType'),
1425
+ capacity: String(requiredBigInt(params, 'capacity')),
1426
+ feePolicy: params.feePolicy,
1427
+ riskPolicy: params.riskPolicy,
1428
+ lockTerms: params.lockTerms,
1429
+ operatorBond: params.operatorBond,
1430
+ metadata: params.metadata,
1431
+ }, (0, liquidity_bond_1.createEmptyLiquidityBondRegistryState)());
1432
+ context.pools.set(created.manifest.poolId, { manifest: created.manifest, registry: created.registry });
1433
+ return { success: true, pool: jsonSafe(created.manifest), registry: jsonSafe(created.registry) };
1434
+ };
1435
+ methods.set('totem_omniaCreateLiquidityPool', createLiquidityPool);
1436
+ methods.set('omnia/createLiquidityPool', createLiquidityPool);
1437
+ const depositToLiquidityPool = async (raw) => {
1438
+ const params = paramsObject(raw);
1439
+ if (!context.pools || !context.poolPositions)
1440
+ throw new Error('Pool stores are not configured');
1441
+ const poolId = requiredString(params, 'poolId');
1442
+ const stored = context.pools.get(poolId);
1443
+ if (!stored)
1444
+ throw new Error(`Pool ${poolId} not found`);
1445
+ const verifier = {
1446
+ verifyDeposit: async (request) => {
1447
+ if (!context.chainProvider)
1448
+ return { valid: false, reason: 'no chain provider' };
1449
+ const coin = await context.chainProvider.getCoin(request.coinId);
1450
+ if (!coin)
1451
+ return { valid: false, reason: 'coin not found' };
1452
+ if (coin.spent)
1453
+ return { valid: false, reason: 'coin is spent' };
1454
+ if (request.tokenId && coin.tokenid !== request.tokenId)
1455
+ return { valid: false, reason: 'token mismatch' };
1456
+ if (request.claimedAmount && BigInt(coin.amount) < BigInt(request.claimedAmount)) {
1457
+ return { valid: false, reason: 'insufficient amount' };
1458
+ }
1459
+ return { valid: true };
1460
+ },
1461
+ };
1462
+ const deposited = await (0, omnia_pool_1.depositToPool)({
1463
+ pool: stored.manifest,
1464
+ lpAddress: requiredString(params, 'lpAddress'),
1465
+ amount: String(requiredBigInt(params, 'amount')),
1466
+ purpose: requiredString(params, 'purpose'),
1467
+ underlyingUtxoRef: typeof params.underlyingUtxoRef === 'string' ? params.underlyingUtxoRef : undefined,
1468
+ underlyingRefs: params.underlyingRefs,
1469
+ metadata: params.metadata,
1470
+ chainProvider: verifier,
1471
+ }, stored.registry);
1472
+ context.pools.set(poolId, { manifest: deposited.pool, registry: deposited.state });
1473
+ context.poolPositions.set(deposited.position.positionId, { position: deposited.position, registry: deposited.state });
1474
+ return {
1475
+ success: true,
1476
+ poolId,
1477
+ position: jsonSafe(deposited.position),
1478
+ receipt: jsonSafe(deposited.receipt),
1479
+ registry: jsonSafe(deposited.state),
1480
+ };
1481
+ };
1482
+ methods.set('totem_omniaDepositToPool', depositToLiquidityPool);
1483
+ methods.set('omnia/depositToPool', depositToLiquidityPool);
1484
+ const allocatePoolCapital = async (raw) => {
1485
+ const params = paramsObject(raw);
1486
+ if (!context.poolPositions)
1487
+ throw new Error('Pool stores are not configured');
1488
+ const positionId = requiredString(params, 'positionId');
1489
+ const stored = context.poolPositions.get(positionId);
1490
+ if (!stored)
1491
+ throw new Error(`Pool position ${positionId} not found`);
1492
+ const allocated = await (0, omnia_pool_1.allocatePositionCapital)({
1493
+ position: stored.position,
1494
+ amount: String(requiredBigInt(params, 'amount')),
1495
+ allocationType: requiredString(params, 'allocationType'),
1496
+ purpose: requiredString(params, 'purpose'),
1497
+ target: params.target,
1498
+ metadata: params.metadata,
1499
+ }, stored.registry);
1500
+ context.poolPositions.set(positionId, { ...stored, position: allocated.position, registry: allocated.registry, allocation: allocated.allocation });
1501
+ return {
1502
+ success: true,
1503
+ position: jsonSafe(allocated.position),
1504
+ allocation: jsonSafe(allocated.allocation),
1505
+ registry: jsonSafe(allocated.registry),
1506
+ };
1507
+ };
1508
+ methods.set('totem_omniaAllocatePoolCapital', allocatePoolCapital);
1509
+ methods.set('omnia/allocatePoolCapital', allocatePoolCapital);
1510
+ const releasePoolAllocation = async (raw) => {
1511
+ const params = paramsObject(raw);
1512
+ if (!context.poolPositions)
1513
+ throw new Error('Pool stores are not configured');
1514
+ const positionId = requiredString(params, 'positionId');
1515
+ const stored = context.poolPositions.get(positionId);
1516
+ if (!stored)
1517
+ throw new Error(`Pool position ${positionId} not found`);
1518
+ if (!stored.allocation)
1519
+ throw new Error(`Pool position ${positionId} has no allocation`);
1520
+ const released = (0, omnia_pool_1.releaseAllocation)({ allocation: stored.allocation, position: stored.position }, stored.registry);
1521
+ context.poolPositions.set(positionId, { ...stored, position: released.position, registry: released.registry, allocation: undefined });
1522
+ return { success: true, position: jsonSafe(released.position), registry: jsonSafe(released.registry) };
1523
+ };
1524
+ methods.set('totem_omniaReleasePoolAllocation', releasePoolAllocation);
1525
+ methods.set('omnia/releasePoolAllocation', releasePoolAllocation);
1526
+ const withdrawPoolLiquidity = async (raw) => {
1527
+ const params = paramsObject(raw);
1528
+ if (!context.pools || !context.poolPositions)
1529
+ throw new Error('Pool stores are not configured');
1530
+ const positionId = requiredString(params, 'positionId');
1531
+ const stored = context.poolPositions.get(positionId);
1532
+ if (!stored)
1533
+ throw new Error(`Pool position ${positionId} not found`);
1534
+ const pool = context.pools.get(stored.position.poolId);
1535
+ if (!pool)
1536
+ throw new Error(`Pool ${stored.position.poolId} not found`);
1537
+ const withdrawn = (0, omnia_pool_1.withdrawLiquidity)(pool.manifest, stored.position, {
1538
+ positionId,
1539
+ amount: String(requiredBigInt(params, 'amount')),
1540
+ recipientAddress: requiredString(params, 'recipientAddress'),
1541
+ reason: typeof params.reason === 'string' ? params.reason : undefined,
1542
+ }, stored.registry);
1543
+ context.poolPositions.set(positionId, { ...stored, position: withdrawn.position, registry: withdrawn.state, intent: withdrawn.intent });
1544
+ return { success: true, intent: jsonSafe(withdrawn.intent), position: jsonSafe(withdrawn.position), registry: jsonSafe(withdrawn.state) };
1545
+ };
1546
+ methods.set('totem_omniaWithdrawPool', withdrawPoolLiquidity);
1547
+ methods.set('omnia/withdrawPool', withdrawPoolLiquidity);
1548
+ const approvePoolWithdrawal = async (raw) => {
1549
+ const params = paramsObject(raw);
1550
+ if (!context.poolPositions)
1551
+ throw new Error('Pool stores are not configured');
1552
+ const positionId = requiredString(params, 'positionId');
1553
+ const stored = context.poolPositions.get(positionId);
1554
+ if (!stored)
1555
+ throw new Error(`Pool position ${positionId} not found`);
1556
+ if (!stored.intent)
1557
+ throw new Error(`Pool position ${positionId} has no withdrawal intent`);
1558
+ const approved = (0, omnia_pool_1.approveWithdrawal)(stored.intent, stored.position, stored.registry);
1559
+ context.poolPositions.set(positionId, { ...stored, registry: approved.registry, intent: approved.intent });
1560
+ return { success: true, intent: jsonSafe(approved.intent), registry: jsonSafe(approved.registry) };
1561
+ };
1562
+ methods.set('totem_omniaApprovePoolWithdrawal', approvePoolWithdrawal);
1563
+ methods.set('omnia/approvePoolWithdrawal', approvePoolWithdrawal);
1564
+ const executePoolPayoutMethod = async (raw) => {
1565
+ const params = paramsObject(raw);
1566
+ if (!context.pools || !context.poolPositions)
1567
+ throw new Error('Pool stores are not configured');
1568
+ const positionId = requiredString(params, 'positionId');
1569
+ const stored = context.poolPositions.get(positionId);
1570
+ if (!stored)
1571
+ throw new Error(`Pool position ${positionId} not found`);
1572
+ if (!stored.intent)
1573
+ throw new Error(`Pool position ${positionId} has no withdrawal intent`);
1574
+ const pool = context.pools.get(stored.position.poolId);
1575
+ if (!pool)
1576
+ throw new Error(`Pool ${stored.position.poolId} not found`);
1577
+ const signIndices = params.signIndices ? signingIndicesParam(params.signIndices) : undefined;
1578
+ if (signIndices && !context.signer)
1579
+ throw new Error('Pool payout signing requires a signer');
1580
+ const paid = await (0, omnia_pool_1.executePoolPayout)({
1581
+ pool: pool.manifest,
1582
+ position: stored.position,
1583
+ intent: stored.intent,
1584
+ recipientAddress: requiredString(params, 'recipientAddress'),
1585
+ settlement: params.settlement,
1586
+ ...(signIndices && context.signer
1587
+ ? { rooting: { signer: context.signer, signIndices } }
1588
+ : {}),
1589
+ }, stored.registry);
1590
+ context.poolPositions.set(positionId, {
1591
+ ...stored,
1592
+ position: paid.position,
1593
+ registry: paid.registry,
1594
+ intent: paid.intent,
1595
+ });
1596
+ return {
1597
+ success: true,
1598
+ intent: jsonSafe(paid.intent),
1599
+ position: jsonSafe(paid.position),
1600
+ registry: jsonSafe(paid.registry),
1601
+ signedTransition: paid.signedTransition ? jsonSafe(paid.signedTransition) : undefined,
1602
+ };
1603
+ };
1604
+ methods.set('totem_omniaExecutePoolPayout', executePoolPayoutMethod);
1605
+ methods.set('omnia/executePoolPayout', executePoolPayoutMethod);
1606
+ }
215
1607
  function createHostMethods(context) {
216
1608
  const methods = new Map();
217
1609
  const register = (name, handler, alias) => {
218
1610
  methods.set(name, handler);
219
1611
  methods.set(alias, handler);
220
1612
  };
1613
+ const observeMethods = (map) => {
1614
+ const emitter = context.observe;
1615
+ if (!emitter)
1616
+ return map;
1617
+ const observed = new Map();
1618
+ const isQuiet = (name) => name.startsWith('totem_omniaGet') || name === 'totem_omniaWhoami' ||
1619
+ name === 'totem_omniaGetManifest';
1620
+ for (const [name, handler] of map) {
1621
+ observed.set(name, async (params) => {
1622
+ if (isQuiet(name))
1623
+ return handler(params);
1624
+ const startedAt = Date.now();
1625
+ const raw = paramsObject(params);
1626
+ const opId = typeof raw.operationId === 'string' ? raw.operationId : undefined;
1627
+ void emitter.emit({
1628
+ domain: 'command', kind: 'command.invoked', phase: 'observed',
1629
+ entity: { type: 'command', id: name },
1630
+ ...(opId ? { operationId: opId } : {}),
1631
+ data: { method: name, params },
1632
+ });
1633
+ try {
1634
+ const result = await handler(params);
1635
+ void emitter.emit({
1636
+ domain: 'command', kind: 'command.completed', phase: 'committed',
1637
+ entity: { type: 'command', id: name },
1638
+ ...(opId ? { operationId: opId } : {}),
1639
+ data: { method: name, durationMs: Date.now() - startedAt, result },
1640
+ });
1641
+ return result;
1642
+ }
1643
+ catch (error) {
1644
+ void emitter.emit({
1645
+ domain: 'command', kind: 'command.failed', phase: 'failed',
1646
+ entity: { type: 'command', id: name },
1647
+ ...(opId ? { operationId: opId } : {}),
1648
+ data: { method: name, durationMs: Date.now() - startedAt, error: error instanceof Error ? error.message : String(error) },
1649
+ });
1650
+ throw error;
1651
+ }
1652
+ });
1653
+ }
1654
+ return observed;
1655
+ };
221
1656
  const getChannels = (raw) => {
222
1657
  const params = paramsObject(raw);
223
1658
  const tokenId = typeof params.tokenId === 'string' ? params.tokenId : undefined;
@@ -238,12 +1673,23 @@ function createHostMethods(context) {
238
1673
  return channelStateView(channel);
239
1674
  };
240
1675
  register('totem_omniaGetChannelState', getChannelState, 'omnia/getChannelState');
1676
+ const normalizeRouteNode = (value) => {
1677
+ const lower = value.toLowerCase();
1678
+ for (const channel of context.channels.values()) {
1679
+ for (const party of channel.parties) {
1680
+ if (party.partyId === value || party.publicKeyDigest.toLowerCase() === lower) {
1681
+ return party.publicKeyDigest;
1682
+ }
1683
+ }
1684
+ }
1685
+ return value;
1686
+ };
241
1687
  const getRoute = async (raw) => {
242
1688
  context.refreshRouting?.();
243
1689
  const params = paramsObject(raw);
244
1690
  const route = await context.routing.getRoute({
245
- from: requiredString(params, 'fromPartyId'),
246
- to: requiredString(params, 'toPartyId'),
1691
+ from: normalizeRouteNode(requiredString(params, 'fromPartyId')),
1692
+ to: normalizeRouteNode(requiredString(params, 'toPartyId')),
247
1693
  amount: requiredBigInt(params, 'amount'),
248
1694
  tokenId: requiredString(params, 'tokenId'),
249
1695
  targetTokenId: typeof params.targetTokenId === 'string' ? params.targetTokenId : undefined,
@@ -278,7 +1724,10 @@ function createHostMethods(context) {
278
1724
  address: context.identity.address,
279
1725
  publicKeyDigest: context.identity.publicKeyDigest,
280
1726
  identityId: context.identity.identityId,
1727
+ settlementAddress: context.localParticipant?.settlementAddress,
281
1728
  delegation: context.identity.delegation,
1729
+ custodyMode: context.custodyMode ?? 'self',
1730
+ p2pHubUrl: context.p2pHubUrl ?? null,
282
1731
  };
283
1732
  };
284
1733
  register('totem_omniaWhoami', whoami, 'omnia/whoami');
@@ -291,8 +1740,14 @@ function createHostMethods(context) {
291
1740
  register('totem_omniaGetManifest', getManifest, 'omnia/getManifest');
292
1741
  // Fail-fast rule: without signing material only the read-only surface is
293
1742
  // registered. Mutation methods must never exist without a signer.
1743
+ // Coordinator mode: no host signing material. Expose the read surface plus
1744
+ // the non-signing prepare/submit flow; wallets supply signatures and indices.
1745
+ if (context.coordinator && !context.readOnly) {
1746
+ registerPreparedPayMethods(methods, context);
1747
+ return observeMethods(methods);
1748
+ }
294
1749
  if (context.readOnly || !context.signer || !context.leaseProvider) {
295
- return methods;
1750
+ return observeMethods(methods);
296
1751
  }
297
1752
  const openChannel = async (raw) => {
298
1753
  const params = paramsObject(raw);
@@ -304,10 +1759,14 @@ function createHostMethods(context) {
304
1759
  publicKeyDigest: remotePublicKeyDigest,
305
1760
  addressIndex: typeof params.remoteAddressIndex === 'number' ? params.remoteAddressIndex : 0,
306
1761
  settlementAddress: typeof params.remoteSettlementAddress === 'string' ? params.remoteSettlementAddress : undefined,
1762
+ transportPubkey: typeof params.remoteTransportPubkey === 'string' ? params.remoteTransportPubkey : undefined,
307
1763
  };
308
1764
  const result = await withOperation(context, params, async () => {
309
1765
  const createParams = {
310
- localParty: context.localParticipant,
1766
+ localParty: {
1767
+ ...context.localParticipant,
1768
+ transportPubkey: context.localParticipant.transportPubkey ?? context.transportPubkey,
1769
+ },
311
1770
  remoteParty: remote,
312
1771
  localAmount: requiredBigInt(params, 'localAmount'),
313
1772
  remoteAmount: requiredBigInt(params, 'remoteAmount'),
@@ -349,7 +1808,26 @@ function createHostMethods(context) {
349
1808
  if (typeof params.fundingScript === 'string') {
350
1809
  createParams.fundingScript = params.fundingScript;
351
1810
  }
352
- createParams.fundingSigner = signer;
1811
+ // If the caller supplied the funding coin's public key, the node's
1812
+ // `sign` command is authoritative for that input. The host channel key
1813
+ // must not sign someone else's funding coin.
1814
+ createParams.fundingSigner = !fundingPubkey && context.signerPort && (context.custodyMode ?? 'self') !== 'hosted'
1815
+ ? async (digest) => context.signerPort.sign({
1816
+ requestId: `channel-funding-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1817
+ kind: 'chain',
1818
+ domain: 'channel',
1819
+ digest,
1820
+ context: { summary: 'Channel funding input' },
1821
+ signers: [context.signerPort.publicKeyDigest],
1822
+ custody: (0, omnia_1.makeSelfCustody)(),
1823
+ expiresAt: Date.now() + 60_000,
1824
+ })
1825
+ : signer;
1826
+ }
1827
+ // RFC-035: establish the peer transport before broadcasting funding.
1828
+ // Otherwise a DHT timeout leaves the funding coin spent with no channel.
1829
+ if (context.swarm) {
1830
+ await context.swarm.connectToPeer(remote.publicKeyDigest, `omnia-open-preflight-${remotePartyId}`, remote.transportPubkey);
353
1831
  }
354
1832
  const created = await (0, omnia_1.createChannel)(createParams, context.chainProvider);
355
1833
  context.channels.set(created.channel.channelId, {
@@ -358,12 +1836,22 @@ function createHostMethods(context) {
358
1836
  });
359
1837
  let acknowledged = true;
360
1838
  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 });
1839
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, created.channel.channelId, remote.transportPubkey);
363
1840
  // Give the peer time to accept so a follow-up pay() is not lost.
364
- const reply = await waitForPeerReply(peer, created.channel.channelId, 20_000);
1841
+ const reply = await requestPeerReply(peer, { type: 'CHANNEL_PROPOSAL', channelId: created.channel.channelId, nonce: Date.now(), payload: created.proposal }, created.channel.channelId, 20_000);
365
1842
  acknowledged = reply?.type === 'ACK';
366
1843
  }
1844
+ await context.observe?.emit({
1845
+ domain: 'channel', kind: 'channel.opened', phase: 'accepted',
1846
+ entity: { type: 'channel', id: created.channel.channelId },
1847
+ parties: { local: context.localParticipant?.partyId, remote: remotePartyId },
1848
+ token: { tokenId: created.channel.tokenId, scale: created.channel.tokenScale ?? 0 },
1849
+ amounts: { capacity: created.channel.totalValue.toString(), balances: balancesJson(created.channel) },
1850
+ chain: { fundingCoinId: created.channel.fundingCoinId, txpowId: created.channel.fundingTxId },
1851
+ transitionHash: (0, emitter_js_1.channelTransitionHash)(created.channel),
1852
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
1853
+ data: { acknowledged },
1854
+ });
367
1855
  return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId, acknowledged };
368
1856
  });
369
1857
  return result;
@@ -384,23 +1872,128 @@ function createHostMethods(context) {
384
1872
  throw new Error('Channel counterparty not found');
385
1873
  if (amount <= 0n || (channel.balances[localPartyId] ?? 0n) < amount)
386
1874
  throw new Error('Insufficient channel balance');
1875
+ const beforeBalances = balancesJson(channel);
387
1876
  const result = await (0, omnia_1.updateState)(channel, {
388
1877
  newBalances: {
389
1878
  ...channel.balances,
390
1879
  [localPartyId]: (channel.balances[localPartyId] ?? 0n) - amount,
391
1880
  [remotePartyId]: (channel.balances[remotePartyId] ?? 0n) + amount,
392
1881
  },
393
- }, context.leaseProvider, context.signer);
1882
+ }, context.leaseProvider, context.signer, context.signerPort);
394
1883
  if (result.error)
395
1884
  throw new Error(result.error);
396
1885
  context.channels.set(channelId, result.channel);
397
- const peer = context.swarm ? await context.swarm.connectToPeer(channel.parties.find((party) => party.partyId === remotePartyId).publicKeyDigest, channelId) : undefined;
1886
+ await context.observe?.emit({
1887
+ domain: 'channel', kind: 'channel.updated', phase: 'committed',
1888
+ entity: { type: 'channel', id: channelId },
1889
+ parties: { local: localPartyId, remote: remotePartyId },
1890
+ token: { tokenId: channel.tokenId, scale: channel.tokenScale ?? 0 },
1891
+ before: { balances: beforeBalances, sequence: channel.currentSequence },
1892
+ after: { balances: balancesJson(result.channel), sequence: result.channel.currentSequence },
1893
+ amounts: { amountAtoms: amount.toString(), balances: balancesJson(result.channel) },
1894
+ transitionHash: (0, emitter_js_1.channelTransitionHash)(result.channel),
1895
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
1896
+ });
1897
+ const remoteParty = channel.parties.find((party) => party.partyId === remotePartyId);
1898
+ const peer = context.swarm ? await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey) : undefined;
398
1899
  if (peer)
399
1900
  await peer.sendMessage({ type: 'STATE_UPDATE', channelId, nonce: Date.now(), payload: result.signedState });
400
1901
  return { success: true, channelId, sequence: result.channel.currentSequence, localBalance: result.channel.balances[localPartyId].toString(), remoteBalance: result.channel.balances[remotePartyId].toString() };
401
1902
  });
402
1903
  };
403
1904
  register('totem_omniaPay', pay, 'omnia/pay');
1905
+ const sendHtlcState = async (channel, state) => {
1906
+ const localPartyId = context.localParticipant.partyId;
1907
+ const remote = channel.parties.find((party) => party.partyId !== localPartyId);
1908
+ if (!context.swarm || !remote)
1909
+ return;
1910
+ const peer = context.channelPeers?.get(channel.channelId)
1911
+ ?? await context.swarm.connectToPeer(remote.publicKeyDigest, channel.channelId, remote.transportPubkey);
1912
+ await peer.sendMessage({ type: 'STATE_UPDATE', channelId: channel.channelId, nonce: Date.now(), payload: { ...state, pendingHTLCs: channel.pendingHTLCs } });
1913
+ };
1914
+ const addHtlcMethod = async (raw) => {
1915
+ const params = paramsObject(raw);
1916
+ const channelId = requiredString(params, 'channelId');
1917
+ requireMutationContext(context);
1918
+ const result = await withOperation(context, params, async () => {
1919
+ const channel = context.channels.get(channelId);
1920
+ if (!channel)
1921
+ throw new Error(`Channel ${channelId} not found`);
1922
+ const added = await (0, omnia_1.addHTLC)(channel, {
1923
+ amount: requiredBigInt(params, 'amount'),
1924
+ hashlock: requiredString(params, 'hashlock'),
1925
+ timeoutBlock: requiredBigInt(params, 'timeoutBlock'),
1926
+ direction: (typeof params.direction === 'string' ? params.direction : 'offered'),
1927
+ counterpartPublicKeyDigest: requiredString(params, 'counterpartPublicKeyDigest'),
1928
+ }, context.leaseProvider, context.signer);
1929
+ if (added.error)
1930
+ throw new Error(added.error);
1931
+ context.channels.set(channelId, added.channel);
1932
+ await context.observe?.emit({
1933
+ domain: 'payment', kind: 'htlc.added', phase: 'committed',
1934
+ entity: { type: 'channel', id: channelId },
1935
+ amounts: { amountAtoms: requiredBigInt(params, 'amount').toString(), balances: balancesJson(added.channel) },
1936
+ transitionHash: (0, emitter_js_1.channelTransitionHash)(added.channel),
1937
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
1938
+ data: { htlcId: added.htlcId, hashlock: params.hashlock, timeoutBlock: String(params.timeoutBlock), direction: params.direction },
1939
+ });
1940
+ await sendHtlcState(added.channel, added.partialState);
1941
+ return { success: true, channelId, htlcId: added.htlcId, sequence: added.channel.currentSequence };
1942
+ });
1943
+ return result;
1944
+ };
1945
+ register('totem_omniaAddHTLC', addHtlcMethod, 'omnia/addHTLC');
1946
+ const fulfillHtlcMethod = async (raw) => {
1947
+ const params = paramsObject(raw);
1948
+ const channelId = requiredString(params, 'channelId');
1949
+ requireMutationContext(context);
1950
+ return withOperation(context, params, async () => {
1951
+ const channel = context.channels.get(channelId);
1952
+ if (!channel)
1953
+ throw new Error(`Channel ${channelId} not found`);
1954
+ const fulfilled = await (0, omnia_1.fulfillHTLC)(channel, requiredString(params, 'htlcId'), requiredString(params, 'preimage'), context.leaseProvider, context.signer);
1955
+ if (fulfilled.error)
1956
+ throw new Error(fulfilled.error);
1957
+ context.channels.set(channelId, fulfilled.channel);
1958
+ await context.observe?.emit({
1959
+ domain: 'payment', kind: 'htlc.fulfilled', phase: 'committed',
1960
+ entity: { type: 'channel', id: channelId },
1961
+ amounts: { balances: balancesJson(fulfilled.channel) },
1962
+ transitionHash: (0, emitter_js_1.channelTransitionHash)(fulfilled.channel),
1963
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
1964
+ data: { htlcId: params.htlcId },
1965
+ });
1966
+ await sendHtlcState(fulfilled.channel, fulfilled.partialState);
1967
+ return { success: true, channelId, sequence: fulfilled.channel.currentSequence };
1968
+ });
1969
+ };
1970
+ register('totem_omniaFulfillHTLC', fulfillHtlcMethod, 'omnia/fulfillHTLC');
1971
+ const timeoutHtlcMethod = async (raw) => {
1972
+ const params = paramsObject(raw);
1973
+ const channelId = requiredString(params, 'channelId');
1974
+ requireMutationContext(context);
1975
+ return withOperation(context, params, async () => {
1976
+ const channel = context.channels.get(channelId);
1977
+ if (!channel)
1978
+ throw new Error(`Channel ${channelId} not found`);
1979
+ const timedOut = await (0, omnia_1.timeoutHTLC)(channel, requiredString(params, 'htlcId'), context.leaseProvider, context.chainProvider, context.signer);
1980
+ if (timedOut.error)
1981
+ throw new Error(timedOut.error);
1982
+ context.channels.set(channelId, timedOut.channel);
1983
+ await context.observe?.emit({
1984
+ domain: 'payment', kind: 'htlc.timed_out', phase: 'recovered',
1985
+ entity: { type: 'channel', id: channelId },
1986
+ amounts: { balances: balancesJson(timedOut.channel) },
1987
+ transitionHash: (0, emitter_js_1.channelTransitionHash)(timedOut.channel),
1988
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
1989
+ data: { htlcId: params.htlcId },
1990
+ });
1991
+ await sendHtlcState(timedOut.channel, timedOut.partialState);
1992
+ return { success: true, channelId, sequence: timedOut.channel.currentSequence };
1993
+ });
1994
+ };
1995
+ register('totem_omniaTimeoutHTLC', timeoutHtlcMethod, 'omnia/timeoutHTLC');
1996
+ registerPreparedPayMethods(methods, context);
404
1997
  const programTransition = async (raw) => {
405
1998
  const params = paramsObject(raw);
406
1999
  requireMutationContext(context);
@@ -467,14 +2060,13 @@ function createHostMethods(context) {
467
2060
  let settlementTxHex;
468
2061
  if (context.swarm && remoteParty) {
469
2062
  const request = (0, omnia_1.buildSettlementSigningRequest)(closing, { partyAddresses });
470
- const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId);
471
- await peer.sendMessage({
2063
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId, remoteParty.transportPubkey);
2064
+ const reply = await requestPeerReply(peer, {
472
2065
  type: 'SETTLEMENT_PROPOSAL',
473
2066
  channelId,
474
2067
  nonce: Date.now(),
475
2068
  payload: { signRequest: true, ...request },
476
- });
477
- const reply = await waitForPeerReply(peer, channelId, 30_000);
2069
+ }, channelId, 30_000);
478
2070
  const replyPayload = reply?.payload;
479
2071
  if (reply?.type !== 'ACK' || typeof replyPayload?.signature !== 'string') {
480
2072
  throw new Error(`Counterparty did not co-sign the settlement (${reply?.type ?? 'timeout'})`);
@@ -494,6 +2086,14 @@ function createHostMethods(context) {
494
2086
  counterpartySignatures,
495
2087
  });
496
2088
  context.channels.set(channelId, (0, omnia_1.markChannelClosed)(closing));
2089
+ await context.observe?.emit({
2090
+ domain: 'settlement', kind: 'settlement.committed', phase: 'confirmed',
2091
+ entity: { type: 'channel', id: channelId },
2092
+ chain: { txpowId: settlement.settlementPayload.txpowId },
2093
+ amounts: { balances: Object.fromEntries(Object.entries(settlement.settlementPayload.balances).map(([k, v]) => [k, v.toString()])) },
2094
+ transitionHash: (0, emitter_js_1.hashTransition)(['settlement', channelId, settlement.settlementPayload.txpowId]),
2095
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2096
+ });
497
2097
  return { success: true, channelId, settlementTxId: settlement.settlementPayload.txpowId, finalBalances: Object.fromEntries(Object.entries(settlement.settlementPayload.balances).map(([key, value]) => [key, value.toString()])) };
498
2098
  });
499
2099
  };
@@ -506,13 +2106,19 @@ function createHostMethods(context) {
506
2106
  if (!channel)
507
2107
  throw new Error(`Channel ${channelId} not found`);
508
2108
  context.channels.set(channelId, (0, omnia_1.markChannelClosed)(channel));
2109
+ await context.observe?.emit({
2110
+ domain: 'channel', kind: 'channel.closed', phase: 'closed',
2111
+ entity: { type: 'channel', id: channelId },
2112
+ transitionHash: (0, emitter_js_1.channelTransitionHash)((0, omnia_1.markChannelClosed)(channel)),
2113
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2114
+ });
509
2115
  return { success: true, channelId };
510
2116
  });
511
2117
  };
512
2118
  register('totem_omniaCloseChannel', closeChannel, 'omnia/closeChannel');
513
2119
  const createFactoryMethod = async (raw) => {
514
2120
  const params = paramsObject(raw);
515
- if (!context.factories || !context.factoryBundles || !context.chainProvider)
2121
+ if (!context.factories || !context.factoryBundles)
516
2122
  throw new Error('Factory manager is not configured');
517
2123
  const participants = params.participants;
518
2124
  if (!Array.isArray(participants) || participants.length < 2)
@@ -528,17 +2134,128 @@ function createHostMethods(context) {
528
2134
  contributionAmount: BigInt(requiredString(item, 'contributionAmount')),
529
2135
  fundingCoinId: typeof item.fundingCoinId === 'string' ? item.fundingCoinId : undefined,
530
2136
  settlementAddress: typeof item.settlementAddress === 'string' ? item.settlementAddress : undefined,
2137
+ transportPubkey: typeof item.transportPubkey === 'string' ? item.transportPubkey : undefined,
531
2138
  };
532
2139
  });
2140
+ const localPartyId = context.localParticipant?.partyId;
2141
+ if (!localPartyId)
2142
+ throw new Error('Local participant is not configured');
2143
+ const localBundle = context.factoryBundles[localPartyId];
2144
+ if (!localBundle)
2145
+ throw new Error('No local factory lease bundle');
2146
+ const remote = parsed.find((participant) => participant.partyId !== localPartyId);
2147
+ const localSigner = context.signerPort && (context.custodyMode ?? 'self') !== 'hosted'
2148
+ ? async (digest) => {
2149
+ const request = {
2150
+ requestId: `factory-funding-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
2151
+ kind: 'chain',
2152
+ domain: 'factory',
2153
+ digest,
2154
+ context: { summary: 'Factory funding input' },
2155
+ signers: [context.signerPort.publicKeyDigest],
2156
+ custody: (0, omnia_1.makeSelfCustody)(),
2157
+ expiresAt: Date.now() + 60_000,
2158
+ };
2159
+ return context.signerPort.sign(request);
2160
+ }
2161
+ : context.signer && context.leaseProvider
2162
+ ? async (digest) => {
2163
+ const reservation = await context.leaseProvider.reserveKeyUse({
2164
+ treeId: 'factory-funding',
2165
+ purpose: 'factory-funding',
2166
+ payloadHash: '0x' + Buffer.from(digest).toString('hex'),
2167
+ });
2168
+ try {
2169
+ const sig = await context.signer.sign(digest, reservation.indices);
2170
+ await context.leaseProvider.commitKeyUse(reservation.reservationId, 'factory-funding');
2171
+ return sig;
2172
+ }
2173
+ catch (err) {
2174
+ await context.leaseProvider.burnReservation(reservation.reservationId, 'factory-funding-failed').catch(() => { });
2175
+ throw err;
2176
+ }
2177
+ }
2178
+ : undefined;
2179
+ let fundingPeer;
2180
+ const remoteSigner = context.swarm && remote
2181
+ ? async (digest) => {
2182
+ fundingPeer ??= await context.swarm.connectToPeer(remote.publicKeyDigest, 'factory-funding', remote.transportPubkey);
2183
+ const channelId = 'factory-funding';
2184
+ const reply = await requestPeerReply(fundingPeer, {
2185
+ type: 'FACTORY_FUNDING_REQUEST',
2186
+ channelId,
2187
+ nonce: Date.now(),
2188
+ payload: { digest: '0x' + Buffer.from(digest).toString('hex') },
2189
+ }, channelId, 30_000, ['FACTORY_FUNDING_RESPONSE', 'ERROR']);
2190
+ const signature = reply?.payload?.signature;
2191
+ if (!signature) {
2192
+ throw new Error(`Counterparty did not co-sign factory funding (${reply?.type ?? 'timeout'})`);
2193
+ }
2194
+ return Uint8Array.from(Buffer.from(signature.replace(/^0x/i, ''), 'hex'));
2195
+ }
2196
+ : undefined;
2197
+ const funding = Array.isArray(params.funding)
2198
+ ? params.funding.map((value) => {
2199
+ if (!value || typeof value !== 'object')
2200
+ throw new Error('Invalid factory funding material');
2201
+ const item = value;
2202
+ const hex = (key) => {
2203
+ const raw = item[key];
2204
+ if (typeof raw !== 'string')
2205
+ return undefined;
2206
+ return Buffer.from(raw.replace(/^0x/i, ''), 'hex');
2207
+ };
2208
+ const coinProof = hex('coinProof');
2209
+ if (!coinProof)
2210
+ throw new Error('factory funding material requires coinProof');
2211
+ const partyId = requiredString(item, 'partyId');
2212
+ const signature = hex('signature');
2213
+ const signer = signature
2214
+ ? undefined
2215
+ : partyId === localPartyId
2216
+ ? localSigner
2217
+ : remoteSigner;
2218
+ return {
2219
+ partyId,
2220
+ coinProof,
2221
+ ...(typeof item.coinAddress === 'string' ? { coinAddress: item.coinAddress } : {}),
2222
+ ...(typeof item.script === 'string' ? { script: item.script } : {}),
2223
+ ...(hex('scriptProof') ? { scriptProof: hex('scriptProof') } : {}),
2224
+ ...(signature ? { signature } : signer ? { signer } : {}),
2225
+ };
2226
+ })
2227
+ : undefined;
533
2228
  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
2229
  return withOperation(context, params, async () => {
539
- const factory = await (0, omnia_factory_1.createFactory)(parsed, tokenId, bundles[proposer.partyId], context.chainProvider);
2230
+ // Off-chain when no funding material is supplied; with funding material the
2231
+ // SDK builds, signs, and submits the on-chain factory funding TX.
2232
+ let factory = await (0, omnia_factory_1.createFactory)(parsed, tokenId, localBundle, context.chainProvider, 0, funding);
2233
+ if (context.swarm && remote) {
2234
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, factory.factoryId, remote.transportPubkey);
2235
+ const reply = await requestPeerReply(peer, {
2236
+ type: 'FACTORY_PROPOSAL',
2237
+ channelId: factory.factoryId,
2238
+ nonce: Date.now(),
2239
+ payload: { factory },
2240
+ }, factory.factoryId, 30_000, ['FACTORY_ACCEPT', 'ERROR']);
2241
+ if (reply?.type !== 'FACTORY_ACCEPT') {
2242
+ throw new Error(`Counterparty did not accept the factory (${reply?.type ?? 'timeout'})`);
2243
+ }
2244
+ const accepted = reply.payload?.factory;
2245
+ if (!accepted)
2246
+ throw new Error('Factory accept reply missing factory');
2247
+ factory = accepted;
2248
+ }
540
2249
  context.factories.set(factory.factoryId, factory);
541
- return { success: true, factoryId: factory.factoryId, fundingTxId: factory.fundingTxId };
2250
+ await context.observe?.emit({
2251
+ domain: 'factory', kind: 'factory.opened', phase: 'accepted',
2252
+ entity: { type: 'factory', id: factory.factoryId },
2253
+ amounts: { balances: Object.fromEntries(Object.entries(factory.allocations ?? {}).map(([k, v]) => [k, v.toString()])) },
2254
+ transitionHash: (0, emitter_js_1.hashTransition)(['factory', factory.factoryId, factory.status]),
2255
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2256
+ data: { participants: factory.participants },
2257
+ });
2258
+ return { success: true, factoryId: factory.factoryId, status: factory.status };
542
2259
  });
543
2260
  };
544
2261
  register('totem_omniaCreateFactory', createFactoryMethod, 'omnia/createFactory');
@@ -557,14 +2274,123 @@ function createHostMethods(context) {
557
2274
  if (!amounts || typeof amounts !== 'object')
558
2275
  throw new Error('amounts is required');
559
2276
  const parsedAmounts = Object.fromEntries(Object.entries(amounts).map(([key, value]) => [key, BigInt(String(value))]));
2277
+ const localPartyId = context.localParticipant?.partyId;
2278
+ if (!localPartyId)
2279
+ throw new Error('Local participant is not configured');
2280
+ const localBundle = context.factoryBundles[localPartyId];
2281
+ if (!localBundle)
2282
+ throw new Error('No local factory lease bundle');
560
2283
  return withOperation(context, params, async () => {
561
- const result = await (0, omnia_factory_1.openVirtualChannel)(factory, parties, parsedAmounts, context.factoryBundles);
2284
+ const plan = (0, omnia_factory_1.buildVirtualChannelOpen)(factory, parties, parsedAmounts);
2285
+ const localSig = await (0, omnia_factory_1.signVirtualChannelOpen)(factory, plan, localBundle);
2286
+ const signatures = { [localSig.partyId]: localSig };
2287
+ const remote = factory.participants.find((participant) => participant.partyId !== localPartyId);
2288
+ const peer = context.swarm && remote
2289
+ ? await context.swarm.connectToPeer(remote.publicKeyDigest, plan.vcId, remote.transportPubkey)
2290
+ : undefined;
2291
+ if (peer) {
2292
+ const reply = await requestPeerReply(peer, {
2293
+ type: 'VC_PROPOSAL',
2294
+ channelId: plan.vcId,
2295
+ nonce: Date.now(),
2296
+ payload: { kind: 'vc-open', factoryId, vcId: plan.vcId, plan },
2297
+ }, plan.vcId, 30_000, ['VC_ACCEPT', 'ERROR']);
2298
+ const sig = reply?.payload?.signature;
2299
+ if (reply?.type !== 'VC_ACCEPT' || !sig) {
2300
+ throw new Error(`Counterparty did not sign the virtual-channel open (${reply?.type ?? 'timeout'})`);
2301
+ }
2302
+ signatures[sig.partyId] = sig;
2303
+ }
2304
+ const result = (0, omnia_factory_1.assembleVirtualChannelOpen)(factory, plan, signatures);
2305
+ await localBundle.leaseProvider.commitKeyUse(localSig.reservationId, `factory-vc-open-${factoryId}`);
2306
+ if (peer) {
2307
+ await peer.sendMessage({
2308
+ type: 'VC_FINALIZED',
2309
+ channelId: plan.vcId,
2310
+ nonce: Date.now(),
2311
+ payload: { kind: 'vc-open', factory: result.factory, channel: result.channel },
2312
+ });
2313
+ }
562
2314
  context.factories.set(factoryId, result.factory);
563
2315
  context.channels.set(result.channel.channelId, result.channel);
2316
+ await context.observe?.emit({
2317
+ domain: 'factory', kind: 'factory.vc_opened', phase: 'accepted',
2318
+ entity: { type: 'channel', id: result.channel.channelId },
2319
+ amounts: { capacity: result.channel.totalValue.toString(), balances: balancesJson(result.channel) },
2320
+ transitionHash: (0, emitter_js_1.channelTransitionHash)(result.channel),
2321
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2322
+ data: { factoryId },
2323
+ });
564
2324
  return { success: true, channelId: result.channel.channelId, factoryId };
565
2325
  });
566
2326
  };
567
2327
  register('totem_omniaOpenVirtualChannel', openVirtual, 'omnia/openVirtualChannel');
2328
+ const closeVirtual = async (raw) => {
2329
+ const params = paramsObject(raw);
2330
+ if (!context.factories || !context.factoryBundles)
2331
+ throw new Error('Factory manager is not configured');
2332
+ const factoryId = requiredString(params, 'factoryId');
2333
+ const channelId = requiredString(params, 'channelId');
2334
+ const factory = context.factories.get(factoryId);
2335
+ if (!factory)
2336
+ throw new Error(`Factory ${factoryId} not found`);
2337
+ const channel = context.channels.get(channelId);
2338
+ if (!channel)
2339
+ throw new Error(`Channel ${channelId} not found`);
2340
+ const localPartyId = context.localParticipant?.partyId;
2341
+ if (!localPartyId)
2342
+ throw new Error('Local participant is not configured');
2343
+ const localBundle = context.factoryBundles[localPartyId];
2344
+ if (!localBundle)
2345
+ throw new Error('No local factory lease bundle');
2346
+ return withOperation(context, params, async () => {
2347
+ const plan = (0, omnia_factory_1.buildVirtualChannelClose)(factory, channel);
2348
+ const localSig = await (0, omnia_factory_1.signVirtualChannelClose)(factory, plan, localBundle);
2349
+ const signatures = { [localSig.partyId]: localSig };
2350
+ const remote = factory.participants.find((participant) => participant.partyId !== localPartyId);
2351
+ const peer = context.swarm && remote
2352
+ ? await context.swarm.connectToPeer(remote.publicKeyDigest, plan.vcId, remote.transportPubkey)
2353
+ : undefined;
2354
+ if (peer) {
2355
+ const reply = await requestPeerReply(peer, {
2356
+ type: 'VC_PROPOSAL',
2357
+ channelId: plan.vcId,
2358
+ nonce: Date.now(),
2359
+ payload: { kind: 'vc-close', factoryId, vcId: plan.vcId, plan },
2360
+ }, plan.vcId, 30_000, ['VC_ACCEPT', 'ERROR']);
2361
+ const sig = reply?.payload?.signature;
2362
+ if (reply?.type !== 'VC_ACCEPT' || !sig) {
2363
+ throw new Error(`Counterparty did not sign the virtual-channel close (${reply?.type ?? 'timeout'})`);
2364
+ }
2365
+ signatures[sig.partyId] = sig;
2366
+ }
2367
+ const updated = (0, omnia_factory_1.assembleVirtualChannelClose)(factory, plan, signatures);
2368
+ await localBundle.leaseProvider.commitKeyUse(localSig.reservationId, `factory-vc-close-${factoryId}`);
2369
+ if (peer) {
2370
+ await peer.sendMessage({
2371
+ type: 'VC_FINALIZED',
2372
+ channelId: plan.vcId,
2373
+ nonce: Date.now(),
2374
+ payload: { kind: 'vc-close', factory: updated, channelId },
2375
+ });
2376
+ }
2377
+ context.factories.set(factoryId, updated);
2378
+ context.channels.delete(channelId);
2379
+ await context.observe?.emit({
2380
+ domain: 'factory', kind: 'factory.vc_closed', phase: 'closed',
2381
+ entity: { type: 'channel', id: channelId },
2382
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2383
+ data: { factoryId, allocations: Object.fromEntries(Object.entries(updated.allocations ?? {}).map(([k, v]) => [k, v.toString()])) },
2384
+ });
2385
+ return {
2386
+ success: true,
2387
+ factoryId,
2388
+ channelId,
2389
+ allocations: Object.fromEntries(Object.entries(updated.allocations).map(([k, v]) => [k, v.toString()])),
2390
+ };
2391
+ });
2392
+ };
2393
+ register('totem_omniaCloseVirtualChannel', closeVirtual, 'omnia/closeVirtualChannel');
568
2394
  const closeFactoryMethod = async (raw) => {
569
2395
  const params = paramsObject(raw);
570
2396
  if (!context.factories || !context.factoryBundles || !context.chainProvider)
@@ -573,29 +2399,132 @@ function createHostMethods(context) {
573
2399
  const factory = context.factories.get(factoryId);
574
2400
  if (!factory)
575
2401
  throw new Error(`Factory ${factoryId} not found`);
2402
+ const coinProofRaw = params.coinProof
2403
+ ?? params.broadcastProofs?.coinProofs;
2404
+ const coinProofHex = Array.isArray(coinProofRaw) ? coinProofRaw[0] : coinProofRaw;
2405
+ const coinProof = typeof coinProofHex === 'string'
2406
+ ? Buffer.from(coinProofHex.replace(/^0x/i, ''), 'hex')
2407
+ : undefined;
576
2408
  return withOperation(context, params, async () => {
577
- const payload = await (0, omnia_factory_1.closeFactory)(factory, context.factoryBundles, context.chainProvider);
2409
+ const localPartyId = context.localParticipant?.partyId;
2410
+ const localBundle = localPartyId ? context.factoryBundles[localPartyId] : undefined;
2411
+ if (!localPartyId || !localBundle)
2412
+ throw new Error('No local factory lease bundle');
2413
+ const remote = factory.participants.find((participant) => participant.partyId !== localPartyId);
2414
+ let payload;
2415
+ if (context.swarm && remote) {
2416
+ // Cooperative close: build/sign locally, collect the counterparty
2417
+ // signature over the same draft, then assemble and broadcast once.
2418
+ const draft = (0, omnia_factory_1.buildFactorySettlement)(factory);
2419
+ const localSig = await (0, omnia_factory_1.signFactorySettlement)(factory, draft, localBundle);
2420
+ const signatures = { [localSig.partyId]: localSig };
2421
+ const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, factoryId, remote.transportPubkey);
2422
+ const message = {
2423
+ type: 'FACTORY_CLOSE_PROPOSAL',
2424
+ channelId: factoryId,
2425
+ nonce: Date.now(),
2426
+ payload: { factoryId, draft },
2427
+ };
2428
+ const reply = await requestPeerReply(peer, message, factoryId, 30_000, ['FACTORY_CLOSE_ACCEPT', 'ERROR']);
2429
+ const remoteSig = reply?.payload?.signature;
2430
+ if (reply?.type !== 'FACTORY_CLOSE_ACCEPT' || !remoteSig) {
2431
+ throw new Error(`Counterparty did not co-sign the factory close (${reply?.type ?? 'timeout'})`);
2432
+ }
2433
+ signatures[remoteSig.partyId] = remoteSig;
2434
+ payload = await (0, omnia_factory_1.assembleFactorySettlement)(factory, draft, signatures, context.chainProvider, { coinProof });
2435
+ await localBundle.leaseProvider.commitKeyUse(localSig.reservationId, `factory-settlement-${factoryId}`);
2436
+ await peer.sendMessage({
2437
+ type: 'FACTORY_CLOSE_FINALIZED',
2438
+ channelId: factoryId,
2439
+ nonce: message.nonce,
2440
+ payload: { factoryId, txId: payload.txpowId },
2441
+ });
2442
+ }
2443
+ else {
2444
+ payload = await (0, omnia_factory_1.closeFactory)(factory, context.factoryBundles, context.chainProvider, { coinProof });
2445
+ }
578
2446
  context.factories.set(factoryId, { ...factory, status: 'closed' });
579
- return { success: true, factoryId, settlementTxId: payload.txpowId, finalAllocations: Object.fromEntries(Object.entries(payload.finalAllocations).map(([key, value]) => [key, value.toString()])) };
2447
+ await context.observe?.emit({
2448
+ domain: 'factory', kind: 'factory.closed', phase: 'closed',
2449
+ entity: { type: 'factory', id: factoryId },
2450
+ chain: { txpowId: payload.txpowId },
2451
+ amounts: { balances: Object.fromEntries(Object.entries(payload.finalAllocations).map(([k, v]) => [k, v.toString()])) },
2452
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2453
+ });
2454
+ const finalAllocations = payload.finalAllocations;
2455
+ return { success: true, factoryId, settlementTxId: payload.txpowId, finalAllocations: Object.fromEntries(Object.entries(finalAllocations).map(([key, value]) => [key, value.toString()])) };
580
2456
  });
581
2457
  };
582
2458
  register('totem_omniaCloseFactory', closeFactoryMethod, 'omnia/closeFactory');
583
2459
  const splice = (type) => async (raw) => {
584
2460
  const params = paramsObject(raw);
585
- if (!context.spliceLeaseProvider || !context.spliceProposals)
2461
+ if (!context.spliceLeaseProvider || !context.spliceProposals || !context.spliceAcceptances) {
586
2462
  throw new Error('Splice manager is not configured');
2463
+ }
587
2464
  const channelId = requiredString(params, 'channelId');
588
2465
  const channel = context.channels.get(channelId);
589
2466
  if (!channel)
590
2467
  throw new Error(`Channel ${channelId} not found`);
2468
+ const localPartyId = context.localParticipant?.partyId;
2469
+ if (!localPartyId)
2470
+ throw new Error('Local participant is not configured');
2471
+ const remote = channel.parties.find((party) => party.partyId !== localPartyId);
2472
+ if (!context.swarm || !remote)
2473
+ throw new Error('Splice requires a counterparty');
2474
+ const swarm = context.swarm;
591
2475
  return withOperation(context, params, async () => {
592
- const quiesced = await (0, omnia_splice_1.quiesceChannel)(channel, context.spliceLeaseProvider);
2476
+ const provider = context.spliceLeaseProvider;
2477
+ const quiesced = await (0, omnia_splice_1.quiesceChannel)(channel, provider);
593
2478
  const proposal = type === 'in'
594
- ? await (0, omnia_splice_1.proposeSpliceIn)(quiesced, requiredString(params, 'additionalCoinId'), requiredBigInt(params, 'additionalAmount'), context.spliceLeaseProvider, params.newBalances)
595
- : await (0, omnia_splice_1.proposeSpliceOut)(quiesced, requiredBigInt(params, 'withdrawAmount'), requiredString(params, 'withdrawAddress'), context.spliceLeaseProvider, params.newBalances);
2479
+ ? await (0, omnia_splice_1.proposeSpliceIn)(quiesced, requiredString(params, 'additionalCoinId'), requiredBigInt(params, 'additionalAmount'), provider, params.newBalances)
2480
+ : await (0, omnia_splice_1.proposeSpliceOut)(quiesced, requiredBigInt(params, 'withdrawAmount'), requiredString(params, 'withdrawAddress'), provider, params.newBalances);
596
2481
  context.spliceProposals.set(proposal.spliceId, proposal);
597
- context.channels.set(channelId, quiesced);
598
- return { success: true, channelId, spliceId: proposal.spliceId, updatedChannelState: proposal.spliceTxHex };
2482
+ const peer = await swarm.connectToPeer(remote.publicKeyDigest, channelId, remote.transportPubkey);
2483
+ const proposalMessage = {
2484
+ type: 'SPLICE_PROPOSAL',
2485
+ channelId,
2486
+ nonce: Date.now(),
2487
+ payload: { proposal },
2488
+ };
2489
+ const reply = await requestPeerReply(peer, proposalMessage, channelId, 30_000, ['SPLICE_ACCEPT', 'ERROR']);
2490
+ const acceptance = reply?.payload?.acceptance;
2491
+ if (reply?.type !== 'SPLICE_ACCEPT' || !acceptance) {
2492
+ throw new Error(`Counterparty did not accept the splice (${reply?.type ?? 'timeout'})`);
2493
+ }
2494
+ context.spliceAcceptances.set(proposal.spliceId, acceptance);
2495
+ const spliced = await (0, omnia_splice_1.finalizeSplice)(quiesced, proposal, acceptance, {
2496
+ ...(provider.broadcast ? { broadcast: provider.broadcast } : {}),
2497
+ proposerLeaseProvider: provider.wotsLease,
2498
+ });
2499
+ context.channels.set(channelId, spliced);
2500
+ await context.observe?.emit({
2501
+ domain: 'splice', kind: 'splice.completed', phase: 'confirmed',
2502
+ entity: { type: 'channel', id: channelId },
2503
+ amounts: { capacity: spliced.totalValue.toString(), balances: Object.fromEntries(Object.entries(spliced.balances).map(([k, v]) => [k, v.toString()])) },
2504
+ chain: { fundingCoinId: spliced.spliceFundingCoinId, txpowId: spliced.spliceFundingTxId },
2505
+ transitionHash: (0, emitter_js_1.hashTransition)(['splice', channelId, spliced.spliceFundingCoinId, spliced.spliceFundingTxId]),
2506
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2507
+ data: { spliceId: proposal.spliceId, spliceType: spliced.spliceType },
2508
+ });
2509
+ await peer.sendMessage({
2510
+ type: 'SPLICE_FINALIZED',
2511
+ channelId,
2512
+ nonce: proposalMessage.nonce,
2513
+ payload: {
2514
+ channel: spliced,
2515
+ txId: spliced.spliceFundingTxId,
2516
+ spliceId: proposal.spliceId,
2517
+ },
2518
+ });
2519
+ return {
2520
+ success: true,
2521
+ channelId,
2522
+ spliceId: proposal.spliceId,
2523
+ spliceType: spliced.spliceType,
2524
+ totalValue: spliced.totalValue.toString(),
2525
+ balances: Object.fromEntries(Object.entries(spliced.balances).map(([partyId, amount]) => [partyId, amount.toString()])),
2526
+ fundingCoinId: spliced.spliceFundingCoinId,
2527
+ };
599
2528
  });
600
2529
  };
601
2530
  register('totem_omniaSpliceIn', splice('in'), 'omnia/spliceIn');
@@ -643,6 +2572,18 @@ function createHostMethods(context) {
643
2572
  : await (0, omnia_router_1.executeMultiHopPayment)(ops, context.channels, parsedRoute, parsedRequest, leaseProviders);
644
2573
  if (!result.success)
645
2574
  throw new Error(result.error ?? 'multi-hop payment failed');
2575
+ await context.observe?.emit({
2576
+ domain: 'payment', kind: 'payment.completed', phase: 'committed',
2577
+ entity: { type: 'payment', id: typeof params.operationId === 'string' ? params.operationId : parsedRequest.hashlock },
2578
+ ...(typeof params.operationId === 'string' ? { operationId: params.operationId } : {}),
2579
+ amounts: { amountAtoms: parsedRequest.amount.toString() },
2580
+ token: { tokenId: parsedRequest.tokenId, scale: 0 },
2581
+ transitionHash: (0, emitter_js_1.hashTransition)(['payment', parsedRequest.hashlock, result.settledHops.join(',')]),
2582
+ data: {
2583
+ settledHops: result.settledHops,
2584
+ hops: parsedRoute.hops.map((h) => ({ channelId: h.channelId, amount: String(h.amount) })),
2585
+ },
2586
+ });
646
2587
  return {
647
2588
  success: true,
648
2589
  preimage: result.preimage,
@@ -651,5 +2592,5 @@ function createHostMethods(context) {
651
2592
  });
652
2593
  };
653
2594
  register('totem_omniaPayMultiHop', payMultiHop, 'omnia/payMultiHop');
654
- return methods;
2595
+ return observeMethods(methods);
655
2596
  }