@totemsdk/omnia-host 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,8 +27,12 @@ async function dispatch(request, methods) {
27
27
  return response(request.id, await method(request.params));
28
28
  }
29
29
  catch (error) {
30
- console.error('[jsonrpc] handler error:', error instanceof Error ? error.stack ?? error.message : String(error));
31
- return errorResponse(request.id, -32000, 'Internal error');
30
+ // Surface the handler's message to the caller. Masking it as "Internal
31
+ // error" made operational failures (broadcast rejection, missing manager,
32
+ // malformed params) impossible to diagnose from the client side.
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ console.error('[jsonrpc] handler error:', error instanceof Error ? error.stack ?? message : message);
35
+ return errorResponse(request.id, -32000, message);
32
36
  }
33
37
  }
34
38
  async function readBody(req) {
@@ -17,6 +17,12 @@ export interface HostApiContext {
17
17
  chainProvider?: ChainStateProvider;
18
18
  swarm?: OmniaSwarm;
19
19
  localParticipant?: ChannelParticipant;
20
+ /**
21
+ * Signs the funding-transaction digest with the funding coin's key, so
22
+ * openChannel can assemble the funding witness without the caller reproducing
23
+ * the funding draft. Configured from the node's `sign` command.
24
+ */
25
+ fundingSigner?: (digest: Uint8Array) => Promise<Uint8Array> | Uint8Array;
20
26
  factories?: Map<string, ChannelFactory>;
21
27
  factoryBundles?: Record<string, WotsLeaseBundle>;
22
28
  spliceProposals?: Map<string, SpliceProposal>;
@@ -48,8 +48,10 @@ function channelStateView(channel) {
48
48
  totalValue: channel.totalValue,
49
49
  currentSequence: channel.currentSequence,
50
50
  fundingAddress: channel.fundingAddress,
51
+ fundingScript: channel.fundingScript,
51
52
  fundingTxId: channel.fundingTxId,
52
53
  fundingCoinId: channel.fundingCoinId,
54
+ contestDelayBlocks: channel.contestDelayBlocks,
53
55
  parties: channel.parties,
54
56
  balances: channel.balances,
55
57
  pendingHTLCs: channel.pendingHTLCs,
@@ -184,6 +186,32 @@ async function persistProgramUpdate(context, channelId, run) {
184
186
  balances: balancesJson(result.channel),
185
187
  };
186
188
  }
189
+ /**
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.
192
+ */
193
+ function waitForPeerReply(peer, channelId, timeoutMs) {
194
+ return new Promise((resolve) => {
195
+ let done = false;
196
+ let unsub;
197
+ const finish = (reply) => {
198
+ if (done)
199
+ return;
200
+ done = true;
201
+ clearTimeout(timer);
202
+ if (unsub)
203
+ unsub();
204
+ resolve(reply);
205
+ };
206
+ unsub = peer.onMessage((msg) => {
207
+ if (msg.channelId !== channelId)
208
+ return;
209
+ if (msg.type === 'ACK' || msg.type === 'ERROR')
210
+ finish(msg);
211
+ });
212
+ const timer = setTimeout(() => finish(null), timeoutMs);
213
+ });
214
+ }
187
215
  function createHostMethods(context) {
188
216
  const methods = new Map();
189
217
  const register = (name, handler, alias) => {
@@ -285,18 +313,58 @@ function createHostMethods(context) {
285
313
  remoteAmount: requiredBigInt(params, 'remoteAmount'),
286
314
  tokenId: typeof params.tokenId === 'string' ? params.tokenId : undefined,
287
315
  fundingCoinId: requiredString(params, 'fundingCoinId'),
288
- fundingWitnessBytes: Buffer.from(requiredString(params, 'fundingWitnessHex').replace(/^0x/i, ''), 'hex'),
316
+ contestDelayBlocks: typeof params.contestDelayBlocks === 'number' ? params.contestDelayBlocks : undefined,
289
317
  };
318
+ const fundingWitnessHex = typeof params.fundingWitnessHex === 'string' ? params.fundingWitnessHex : undefined;
319
+ if (fundingWitnessHex) {
320
+ createParams.fundingWitnessBytes = Buffer.from(fundingWitnessHex.replace(/^0x/i, ''), 'hex');
321
+ }
322
+ else {
323
+ // Assemble the funding witness: createChannel builds the funding draft,
324
+ // a funding signer signs its digest, and the caller supplies the input
325
+ // proofs (serialized CoinProof + ScriptProof). The signer comes from the
326
+ // configured funding signer, or from the node's `sign` command using the
327
+ // `fundingPubkey` the caller passes.
328
+ const fundingPubkey = typeof params.fundingPubkey === 'string' ? params.fundingPubkey : undefined;
329
+ const nodeAdapter = context.chainProvider;
330
+ const signer = context.fundingSigner ?? (fundingPubkey && typeof nodeAdapter?.signData === 'function'
331
+ ? async (digest) => {
332
+ const sig = await nodeAdapter.signData(fundingPubkey, '0x' + Buffer.from(digest).toString('hex'));
333
+ return Uint8Array.from(Buffer.from(sig.replace(/^0x/i, ''), 'hex'));
334
+ }
335
+ : undefined);
336
+ if (!signer) {
337
+ throw new Error('openChannel requires fundingWitnessHex, or a fundingPubkey + funding proofs');
338
+ }
339
+ if (typeof params.fundingScript !== 'string' && !Array.isArray(params.fundingScriptProofsHex)) {
340
+ throw new Error('openChannel requires fundingScript (or serialized fundingScriptProofsHex) for the funding input address');
341
+ }
342
+ const toBytes = (v) => Array.isArray(v) ? v.map((h) => Buffer.from(String(h).replace(/^0x/i, ''), 'hex')) : [];
343
+ createParams.fundingProofs = {
344
+ coinProofs: toBytes(params.fundingCoinProofsHex),
345
+ scriptProofs: toBytes(params.fundingScriptProofsHex),
346
+ };
347
+ // The funding coin's address script: createChannel derives the required
348
+ // single-leaf ScriptProof from it, so callers need not hand-roll it.
349
+ if (typeof params.fundingScript === 'string') {
350
+ createParams.fundingScript = params.fundingScript;
351
+ }
352
+ createParams.fundingSigner = signer;
353
+ }
290
354
  const created = await (0, omnia_1.createChannel)(createParams, context.chainProvider);
291
355
  context.channels.set(created.channel.channelId, {
292
356
  ...created.channel,
293
357
  localSigner: context.signer,
294
358
  });
359
+ let acknowledged = true;
295
360
  if (context.swarm) {
296
361
  const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, created.channel.channelId);
297
362
  await peer.sendMessage({ type: 'CHANNEL_PROPOSAL', channelId: created.channel.channelId, nonce: Date.now(), payload: created.proposal });
363
+ // 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);
365
+ acknowledged = reply?.type === 'ACK';
298
366
  }
299
- return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId };
367
+ return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId, acknowledged };
300
368
  });
301
369
  return result;
302
370
  };
@@ -387,11 +455,43 @@ function createHostMethods(context) {
387
455
  if (!channel)
388
456
  throw new Error(`Channel ${channelId} not found`);
389
457
  const closing = (0, omnia_1.markChannelClosing)(channel, 'mutual');
458
+ const partyAddresses = Object.fromEntries(closing.parties.map((party) => [party.partyId, party.settlementAddress ?? party.publicKeyDigest]));
459
+ // Cooperative close needs both eltoo MULTISIG(2 A B) signatures. Build the
460
+ // exact settlement draft, ask the counterparty to co-sign it, then include
461
+ // both signatures in the broadcast witness.
462
+ const localPartyId = context.localParticipant?.partyId;
463
+ const remoteParty = localPartyId
464
+ ? closing.parties.find((party) => party.partyId !== localPartyId)
465
+ : undefined;
466
+ let counterpartySignatures;
467
+ let settlementTxHex;
468
+ if (context.swarm && remoteParty) {
469
+ const request = (0, omnia_1.buildSettlementSigningRequest)(closing, { partyAddresses });
470
+ const peer = await context.swarm.connectToPeer(remoteParty.publicKeyDigest, channelId);
471
+ await peer.sendMessage({
472
+ type: 'SETTLEMENT_PROPOSAL',
473
+ channelId,
474
+ nonce: Date.now(),
475
+ payload: { signRequest: true, ...request },
476
+ });
477
+ const reply = await waitForPeerReply(peer, channelId, 30_000);
478
+ const replyPayload = reply?.payload;
479
+ if (reply?.type !== 'ACK' || typeof replyPayload?.signature !== 'string') {
480
+ throw new Error(`Counterparty did not co-sign the settlement (${reply?.type ?? 'timeout'})`);
481
+ }
482
+ counterpartySignatures = [{
483
+ partyId: replyPayload.partyId ?? remoteParty.partyId,
484
+ signature: Buffer.from(replyPayload.signature.replace(/^0x/i, ''), 'hex'),
485
+ }];
486
+ settlementTxHex = request.settlementTxHex;
487
+ }
390
488
  const settlement = await (0, omnia_1.proposeSettlement)(closing, context.leaseProvider, {
391
489
  signer: context.signer,
392
490
  chainProvider: context.chainProvider,
393
491
  broadcastProofs: parsedProofs,
394
- partyAddresses: Object.fromEntries(closing.parties.map((party) => [party.partyId, party.settlementAddress ?? party.publicKeyDigest])),
492
+ partyAddresses,
493
+ settlementTxHex,
494
+ counterpartySignatures,
395
495
  });
396
496
  context.channels.set(channelId, (0, omnia_1.markChannelClosed)(closing));
397
497
  return { success: true, channelId, settlementTxId: settlement.settlementPayload.txpowId, finalBalances: Object.fromEntries(Object.entries(settlement.settlementPayload.balances).map(([key, value]) => [key, value.toString()])) };
@@ -16,6 +16,12 @@ export interface ConfirmationOptions {
16
16
  }
17
17
  export interface TotemNodeAdapter extends ChainStateProvider {
18
18
  getMode(): Promise<string | undefined>;
19
+ /**
20
+ * Sign arbitrary data with a wallet key (`sign publickey:<0x> data:<0x>`).
21
+ * Used to produce the funding witness signature: createChannel builds the
22
+ * funding digest, and this signs it with the funding coin's key.
23
+ */
24
+ signData(pubkeyHex: string, dataHex: string): Promise<string>;
19
25
  waitForConfirmation(coinId: string, options?: ConfirmationOptions): Promise<void>;
20
26
  subscribe(listener: (tip: ChainTip) => void): () => void;
21
27
  close(): void;
@@ -37,6 +37,10 @@ function createTotemNodeAdapter(options) {
37
37
  searchTokens: (query) => provider.searchTokens(query),
38
38
  getTokensByCreator: (address) => provider.getTokensByCreator(address),
39
39
  broadcastTxPoW: (txpowHex) => provider.broadcastTxPoW(txpowHex),
40
+ getMinTxPowWork: () => provider.getMinTxPowWork ? provider.getMinTxPowWork() : Promise.resolve(null),
41
+ ...(provider.submitTransaction
42
+ ? { submitTransaction: provider.submitTransaction.bind(provider) }
43
+ : {}),
40
44
  async getMode() {
41
45
  const result = await client.runCommand('getmode');
42
46
  if (!result || typeof result !== 'object')
@@ -44,6 +48,21 @@ function createTotemNodeAdapter(options) {
44
48
  const mode = result.mode;
45
49
  return typeof mode === 'string' ? mode : undefined;
46
50
  },
51
+ async signData(pubkeyHex, dataHex) {
52
+ const result = await client.runCommand('sign', { publickey: pubkeyHex, data: dataHex });
53
+ const unwrap = (v) => {
54
+ if (typeof v === 'string')
55
+ return v;
56
+ if (v && typeof v === 'object' && typeof v.response === 'string') {
57
+ return v.response;
58
+ }
59
+ return undefined;
60
+ };
61
+ const sig = unwrap(result) ?? unwrap(result?.response);
62
+ if (!sig)
63
+ throw new Error('signData: unexpected node response');
64
+ return sig;
65
+ },
47
66
  async waitForConfirmation(coinId, confirmationOptions = {}) {
48
67
  const timeoutMs = confirmationOptions.timeoutMs ?? 120_000;
49
68
  const interval = confirmationOptions.pollIntervalMs ?? pollIntervalMs;
package/dist/config.d.ts CHANGED
@@ -11,6 +11,12 @@ export interface OmniaHostConfig {
11
11
  localAddressIndex?: number;
12
12
  localSettlementAddress?: string;
13
13
  nodeMode?: string;
14
+ /**
15
+ * Public key (0x…) that owns the funding coin. When set, the host signs the
16
+ * funding-transaction digest via the node's `sign` command to assemble the
17
+ * channel funding witness (OMNIA_FUNDING_PUBKEY).
18
+ */
19
+ fundingPubkey?: string;
14
20
  wsPath: string;
15
21
  /** BIP39 mnemonic or hex seed (same formats @totemsdk/core accepts). */
16
22
  seed?: string;
package/dist/config.js CHANGED
@@ -75,6 +75,7 @@ function loadConfigFromEnv(env = process.env) {
75
75
  localAddressIndex: optionalInteger(env.OMNIA_LOCAL_ADDRESS_INDEX),
76
76
  localSettlementAddress: optional(env.OMNIA_LOCAL_SETTLEMENT_ADDRESS),
77
77
  nodeMode: optional(env.OMNIA_NODE_MODE),
78
+ fundingPubkey: optional(env.OMNIA_FUNDING_PUBKEY),
78
79
  wsPath: env.OMNIA_WS_PATH?.trim() || '/rpc',
79
80
  seed,
80
81
  keyfile,
package/dist/lifecycle.js CHANGED
@@ -107,6 +107,12 @@ function createOmniaHost(config, dependencies = {}) {
107
107
  spliceProposals: dependencies.spliceProposals,
108
108
  spliceAcceptances: dependencies.spliceAcceptances,
109
109
  spliceLeaseProvider: dependencies.spliceLeaseProvider,
110
+ fundingSigner: config.fundingPubkey
111
+ ? async (digest) => {
112
+ const sig = await chainAdapter.signData(config.fundingPubkey, '0x' + Buffer.from(digest).toString('hex'));
113
+ return Uint8Array.from(Buffer.from(sig.replace(/^0x/i, ''), 'hex'));
114
+ }
115
+ : undefined,
110
116
  readOnly,
111
117
  identity: dependencies.identity,
112
118
  manifest: dependencies.manifest,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totemsdk/omnia-host",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Durable Omnia node daemon for channel lifecycle, routing, and control APIs",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -22,17 +22,17 @@
22
22
  "dependencies": {
23
23
  "better-sqlite3": "^12.2.0",
24
24
  "ws": "^8.21.1",
25
- "@totemsdk/chain-provider": "^0.2.1",
25
+ "@totemsdk/chain-provider": "^0.3.0",
26
+ "@totemsdk/core": "^1.4.0",
26
27
  "@totemsdk/identity": "^1.0.1",
27
- "@totemsdk/core": "^1.2.11",
28
+ "@totemsdk/omnia": "^2.2.0",
28
29
  "@totemsdk/manifest": "^2.0.0",
29
- "@totemsdk/omnia": "^2.0.1",
30
- "@totemsdk/omnia-factory": "^0.5.0",
30
+ "@totemsdk/omnia-factory": "^0.6.0",
31
31
  "@totemsdk/omnia-router": "^0.3.0",
32
32
  "@totemsdk/omnia-splice": "^0.3.0",
33
- "@totemsdk/omnia-vtxo": "^0.5.0",
34
- "@totemsdk/minima-rpc": "^0.2.2",
33
+ "@totemsdk/omnia-vtxo": "^0.6.0",
35
34
  "@totemsdk/storage": "^0.1.0",
35
+ "@totemsdk/minima-rpc": "^0.4.0",
36
36
  "@totemsdk/wots-lease": "^1.0.1"
37
37
  },
38
38
  "devDependencies": {
@@ -43,7 +43,7 @@
43
43
  "jest": "^30.4.2",
44
44
  "ts-jest": "^29.4.11",
45
45
  "typescript": "5.9.3",
46
- "@totemsdk/txpow": "^1.0.2"
46
+ "@totemsdk/txpow": "^1.1.0"
47
47
  },
48
48
  "engines": {
49
49
  "node": ">=20.0.0"