@totemsdk/omnia-host 0.7.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>;
@@ -34,4 +40,9 @@ export interface HostApiContext {
34
40
  /** Boot-time signed EdgeServiceManifest. */
35
41
  manifest?: unknown;
36
42
  }
43
+ /**
44
+ * CAN-006: a fully serializable channel-state view so RPC/HTTP consumers can
45
+ * read everything they need without reaching into `getStore()`/`getMethods()`.
46
+ */
47
+ export declare function channelStateView(channel: OmniaChannel): Record<string, unknown>;
37
48
  export declare function createHostMethods(context: HostApiContext): Map<string, JsonRpcHandler>;
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.channelStateView = channelStateView;
3
4
  exports.createHostMethods = createHostMethods;
4
5
  const omnia_1 = require("@totemsdk/omnia");
5
6
  const omnia_router_1 = require("@totemsdk/omnia-router");
@@ -17,6 +18,59 @@ function channelSummary(channel) {
17
18
  currentSequence: channel.currentSequence,
18
19
  };
19
20
  }
21
+ /** Recursively JSON-safe: bigint → decimal string, bytes → 0x-hex. */
22
+ function jsonSafe(value) {
23
+ if (typeof value === 'bigint')
24
+ return value.toString();
25
+ if (value instanceof Uint8Array)
26
+ return `0x${Buffer.from(value).toString('hex')}`;
27
+ if (Array.isArray(value))
28
+ return value.map(jsonSafe);
29
+ if (value && typeof value === 'object') {
30
+ const out = {};
31
+ for (const [key, val] of Object.entries(value))
32
+ out[key] = jsonSafe(val);
33
+ return out;
34
+ }
35
+ return value;
36
+ }
37
+ /**
38
+ * CAN-006: a fully serializable channel-state view so RPC/HTTP consumers can
39
+ * read everything they need without reaching into `getStore()`/`getMethods()`.
40
+ */
41
+ function channelStateView(channel) {
42
+ return jsonSafe({
43
+ channelId: channel.channelId,
44
+ status: channel.status,
45
+ channelType: channel.channelType,
46
+ tokenId: channel.tokenId,
47
+ tokenScale: channel.tokenScale ?? 0,
48
+ totalValue: channel.totalValue,
49
+ currentSequence: channel.currentSequence,
50
+ fundingAddress: channel.fundingAddress,
51
+ fundingScript: channel.fundingScript,
52
+ fundingTxId: channel.fundingTxId,
53
+ fundingCoinId: channel.fundingCoinId,
54
+ contestDelayBlocks: channel.contestDelayBlocks,
55
+ parties: channel.parties,
56
+ balances: channel.balances,
57
+ pendingHTLCs: channel.pendingHTLCs,
58
+ latestState: channel.latestState
59
+ ? {
60
+ sequence: channel.latestState.sequence,
61
+ balances: channel.latestState.balances,
62
+ pendingHTLCs: channel.latestState.pendingHTLCs?.length ?? 0,
63
+ stateVariables: channel.latestState.stateVariables,
64
+ transactionHex: channel.latestState.transactionHex,
65
+ signatures: Object.keys(channel.latestState.signatures ?? {}),
66
+ signingIndices: channel.latestState.signingIndices,
67
+ }
68
+ : null,
69
+ stateLogLength: channel.stateLog?.length ?? 0,
70
+ createdAt: channel.createdAt,
71
+ updatedAt: channel.updatedAt,
72
+ });
73
+ }
20
74
  function paramsObject(params) {
21
75
  if (!params || typeof params !== 'object' || Array.isArray(params))
22
76
  return {};
@@ -132,6 +186,32 @@ async function persistProgramUpdate(context, channelId, run) {
132
186
  balances: balancesJson(result.channel),
133
187
  };
134
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
+ }
135
215
  function createHostMethods(context) {
136
216
  const methods = new Map();
137
217
  const register = (name, handler, alias) => {
@@ -149,6 +229,15 @@ function createHostMethods(context) {
149
229
  return { channels };
150
230
  };
151
231
  register('totem_omniaGetChannels', getChannels, 'omnia/getChannels');
232
+ const getChannelState = (raw) => {
233
+ const params = paramsObject(raw);
234
+ const channelId = requiredString(params, 'channelId');
235
+ const channel = context.channels.get(channelId);
236
+ if (!channel)
237
+ throw new Error(`Channel ${channelId} not found`);
238
+ return channelStateView(channel);
239
+ };
240
+ register('totem_omniaGetChannelState', getChannelState, 'omnia/getChannelState');
152
241
  const getRoute = async (raw) => {
153
242
  context.refreshRouting?.();
154
243
  const params = paramsObject(raw);
@@ -224,18 +313,58 @@ function createHostMethods(context) {
224
313
  remoteAmount: requiredBigInt(params, 'remoteAmount'),
225
314
  tokenId: typeof params.tokenId === 'string' ? params.tokenId : undefined,
226
315
  fundingCoinId: requiredString(params, 'fundingCoinId'),
227
- fundingWitnessBytes: Buffer.from(requiredString(params, 'fundingWitnessHex').replace(/^0x/i, ''), 'hex'),
316
+ contestDelayBlocks: typeof params.contestDelayBlocks === 'number' ? params.contestDelayBlocks : undefined,
228
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
+ }
229
354
  const created = await (0, omnia_1.createChannel)(createParams, context.chainProvider);
230
355
  context.channels.set(created.channel.channelId, {
231
356
  ...created.channel,
232
357
  localSigner: context.signer,
233
358
  });
359
+ let acknowledged = true;
234
360
  if (context.swarm) {
235
361
  const peer = await context.swarm.connectToPeer(remote.publicKeyDigest, created.channel.channelId);
236
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';
237
366
  }
238
- return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId };
367
+ return { success: true, channelId: created.channel.channelId, fundingTxId: created.channel.fundingTxId, acknowledged };
239
368
  });
240
369
  return result;
241
370
  };
@@ -326,11 +455,43 @@ function createHostMethods(context) {
326
455
  if (!channel)
327
456
  throw new Error(`Channel ${channelId} not found`);
328
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
+ }
329
488
  const settlement = await (0, omnia_1.proposeSettlement)(closing, context.leaseProvider, {
330
489
  signer: context.signer,
331
490
  chainProvider: context.chainProvider,
332
491
  broadcastProofs: parsedProofs,
333
- partyAddresses: Object.fromEntries(closing.parties.map((party) => [party.partyId, party.settlementAddress ?? party.publicKeyDigest])),
492
+ partyAddresses,
493
+ settlementTxHex,
494
+ counterpartySignatures,
334
495
  });
335
496
  context.channels.set(channelId, (0, omnia_1.markChannelClosed)(closing));
336
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.7.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",
26
- "@totemsdk/core": "^1.2.11",
27
- "@totemsdk/manifest": "^2.0.0",
28
- "@totemsdk/omnia": "^2.0.1",
25
+ "@totemsdk/chain-provider": "^0.3.0",
26
+ "@totemsdk/core": "^1.4.0",
29
27
  "@totemsdk/identity": "^1.0.1",
28
+ "@totemsdk/omnia": "^2.2.0",
29
+ "@totemsdk/manifest": "^2.0.0",
30
+ "@totemsdk/omnia-factory": "^0.6.0",
30
31
  "@totemsdk/omnia-router": "^0.3.0",
31
- "@totemsdk/omnia-factory": "^0.5.0",
32
- "@totemsdk/omnia-vtxo": "^0.5.0",
33
32
  "@totemsdk/omnia-splice": "^0.3.0",
33
+ "@totemsdk/omnia-vtxo": "^0.6.0",
34
34
  "@totemsdk/storage": "^0.1.0",
35
- "@totemsdk/minima-rpc": "^0.2.2",
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"