@totemsdk/omnia-vtxo 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/anchor.d.ts CHANGED
@@ -11,6 +11,12 @@ import { type OmniaTxDraft, type StateValue } from '@totemsdk/omnia';
11
11
  import type { ChainStateProvider } from '@totemsdk/chain-provider';
12
12
  export interface BatchAnchorParams {
13
13
  operatorPublicKeyDigest: string;
14
+ /** Holder WOTS public-key digests (one per pool share). */
15
+ holders: string[];
16
+ /** Committed share per holder (aligned with `holders`); Σ = pool capacity. */
17
+ shares: bigint[];
18
+ /** Absolute block height after which holders may exit unilaterally (STATE(102)). */
19
+ exitBlock: number;
14
20
  /** The epoch's Merkle root (32-byte hex). */
15
21
  root: string;
16
22
  epoch: number;
@@ -22,8 +28,8 @@ export interface BatchAnchorParams {
22
28
  amount: bigint;
23
29
  tokenId: string;
24
30
  }
25
- /** State variables carried by the pool output: STATE(100)=root, STATE(101)=epoch. */
26
- export declare function poolStateVariables(root: string, epoch: number): StateValue[];
31
+ /** Pool output state: STATE(100)=root, STATE(101)=epoch, STATE(102)=exitBlock, STATE(300+k)=share k. */
32
+ export declare function poolStateVariables(root: string, epoch: number, exitBlock: number, shares?: bigint[]): StateValue[];
27
33
  /** Build the batch-anchor draft (input = pool coin, output = pool state output). */
28
34
  export declare function buildBatchAnchorDraft(params: BatchAnchorParams): OmniaTxDraft;
29
35
  export declare function batchAnchorDigest(draft: OmniaTxDraft): Uint8Array;
package/dist/anchor.js CHANGED
@@ -10,25 +10,34 @@
10
10
  import { computeOmniaTxDigest, omniaDraftToMinimaBytes, } from '@totemsdk/omnia';
11
11
  import { serializeTxBody, mineTxPoW, TX_POW_MIN_DIFFICULTY } from '@totemsdk/txpow';
12
12
  import { concatBytes } from '@totemsdk/core';
13
- import { buildAndHashPoolScript } from './pool-script.js';
14
- /** State variables carried by the pool output: STATE(100)=root, STATE(101)=epoch. */
15
- export function poolStateVariables(root, epoch) {
13
+ import { buildAndHashPoolScript, POOL_SHARE_PORT_BASE } from './pool-script.js';
14
+ /** Pool output state: STATE(100)=root, STATE(101)=epoch, STATE(102)=exitBlock, STATE(300+k)=share k. */
15
+ export function poolStateVariables(root, epoch, exitBlock, shares = []) {
16
16
  return [
17
17
  { port: 100, value: root, type: 'string' },
18
18
  { port: 101, value: BigInt(epoch), type: 'number' },
19
+ { port: 102, value: BigInt(exitBlock), type: 'number' },
20
+ ...shares.map((share, k) => ({ port: POOL_SHARE_PORT_BASE + k, value: share, type: 'number' })),
19
21
  ];
20
22
  }
21
23
  /** Build the batch-anchor draft (input = pool coin, output = pool state output). */
22
24
  export function buildBatchAnchorDraft(params) {
23
- const { script, address } = buildAndHashPoolScript(params.operatorPublicKeyDigest);
25
+ if (params.holders.length !== params.shares.length) {
26
+ throw new Error('anchorBatch: holders and shares must be the same length');
27
+ }
28
+ const { script, address } = buildAndHashPoolScript({
29
+ operatorPublicKeyDigest: params.operatorPublicKeyDigest,
30
+ holders: params.holders,
31
+ exitBlock: params.exitBlock,
32
+ });
24
33
  // The input must be the operator's own pool output, not a foreign script.
25
34
  if (params.fundingScript !== script) {
26
- throw new Error('anchorBatch: fundingScript does not match the operator pool script');
35
+ throw new Error('anchorBatch: fundingScript does not match the pool script');
27
36
  }
28
37
  if (params.fundingAddress !== address) {
29
- throw new Error('anchorBatch: fundingAddress does not match the operator pool address');
38
+ throw new Error('anchorBatch: fundingAddress does not match the pool address');
30
39
  }
31
- const stateVariables = poolStateVariables(params.root, params.epoch);
40
+ const stateVariables = poolStateVariables(params.root, params.epoch, params.exitBlock, params.shares);
32
41
  return {
33
42
  type: 'settlement',
34
43
  inputs: [{
@@ -11,6 +11,12 @@ import { type OmniaTxDraft, type StateValue } from '@totemsdk/omnia';
11
11
  import type { ChainStateProvider } from '@totemsdk/chain-provider';
12
12
  export interface BatchAnchorParams {
13
13
  operatorPublicKeyDigest: string;
14
+ /** Holder WOTS public-key digests (one per pool share). */
15
+ holders: string[];
16
+ /** Committed share per holder (aligned with `holders`); Σ = pool capacity. */
17
+ shares: bigint[];
18
+ /** Absolute block height after which holders may exit unilaterally (STATE(102)). */
19
+ exitBlock: number;
14
20
  /** The epoch's Merkle root (32-byte hex). */
15
21
  root: string;
16
22
  epoch: number;
@@ -22,8 +28,8 @@ export interface BatchAnchorParams {
22
28
  amount: bigint;
23
29
  tokenId: string;
24
30
  }
25
- /** State variables carried by the pool output: STATE(100)=root, STATE(101)=epoch. */
26
- export declare function poolStateVariables(root: string, epoch: number): StateValue[];
31
+ /** Pool output state: STATE(100)=root, STATE(101)=epoch, STATE(102)=exitBlock, STATE(300+k)=share k. */
32
+ export declare function poolStateVariables(root: string, epoch: number, exitBlock: number, shares?: bigint[]): StateValue[];
27
33
  /** Build the batch-anchor draft (input = pool coin, output = pool state output). */
28
34
  export declare function buildBatchAnchorDraft(params: BatchAnchorParams): OmniaTxDraft;
29
35
  export declare function batchAnchorDigest(draft: OmniaTxDraft): Uint8Array;
@@ -18,24 +18,33 @@ const omnia_1 = require("@totemsdk/omnia");
18
18
  const txpow_1 = require("@totemsdk/txpow");
19
19
  const core_1 = require("@totemsdk/core");
20
20
  const pool_script_js_1 = require("./pool-script.js");
21
- /** State variables carried by the pool output: STATE(100)=root, STATE(101)=epoch. */
22
- function poolStateVariables(root, epoch) {
21
+ /** Pool output state: STATE(100)=root, STATE(101)=epoch, STATE(102)=exitBlock, STATE(300+k)=share k. */
22
+ function poolStateVariables(root, epoch, exitBlock, shares = []) {
23
23
  return [
24
24
  { port: 100, value: root, type: 'string' },
25
25
  { port: 101, value: BigInt(epoch), type: 'number' },
26
+ { port: 102, value: BigInt(exitBlock), type: 'number' },
27
+ ...shares.map((share, k) => ({ port: pool_script_js_1.POOL_SHARE_PORT_BASE + k, value: share, type: 'number' })),
26
28
  ];
27
29
  }
28
30
  /** Build the batch-anchor draft (input = pool coin, output = pool state output). */
29
31
  function buildBatchAnchorDraft(params) {
30
- const { script, address } = (0, pool_script_js_1.buildAndHashPoolScript)(params.operatorPublicKeyDigest);
32
+ if (params.holders.length !== params.shares.length) {
33
+ throw new Error('anchorBatch: holders and shares must be the same length');
34
+ }
35
+ const { script, address } = (0, pool_script_js_1.buildAndHashPoolScript)({
36
+ operatorPublicKeyDigest: params.operatorPublicKeyDigest,
37
+ holders: params.holders,
38
+ exitBlock: params.exitBlock,
39
+ });
31
40
  // The input must be the operator's own pool output, not a foreign script.
32
41
  if (params.fundingScript !== script) {
33
- throw new Error('anchorBatch: fundingScript does not match the operator pool script');
42
+ throw new Error('anchorBatch: fundingScript does not match the pool script');
34
43
  }
35
44
  if (params.fundingAddress !== address) {
36
- throw new Error('anchorBatch: fundingAddress does not match the operator pool address');
45
+ throw new Error('anchorBatch: fundingAddress does not match the pool address');
37
46
  }
38
- const stateVariables = poolStateVariables(params.root, params.epoch);
47
+ const stateVariables = poolStateVariables(params.root, params.epoch, params.exitBlock, params.shares);
39
48
  return {
40
49
  type: 'settlement',
41
50
  inputs: [{
@@ -1,32 +1,40 @@
1
1
  /**
2
- * VTXO exit transaction (RFC-038 V2).
2
+ * VTXO exit transaction (RFC-038 V3 — per-share, owner-signed).
3
3
  *
4
- * Builds a real exit transaction: spend the pool's on-chain coin and pay the
5
- * VTXO's owner, carrying the exit delay in STATE(102). The digest is signed
6
- * (injected signer) and, when a chain provider is supplied, mined + broadcast
7
- * (injected miner). This replaces the `'mock-exit'` draft with a concrete tx.
4
+ * The exit spends the pool's on-chain coin and pays the exiting holder **only
5
+ * its committed share** (`shares[holderIndex]`), returning the remainder to the
6
+ * pool with that holder's share state zeroed. It is signed with the holder's key
7
+ * — the pool script's `IF @BLOCK GT EXITBLOCK … IF SIGNEDBY(holder) … VERIFYOUT(0
8
+ * holder SHARE @TOKENID FALSE)` branch authorizes it, so the holder can broadcast
9
+ * without any operator signature or liveness, and cannot take more than its share.
8
10
  *
9
- * NOTE (RFC-038 §3.4): the full owner-unilateral covenant (a timelock branch the
10
- * owner can exercise without the operator, or a pre-signed exit tx) is a
11
- * follow-on; this builds and broadcasts the transaction with the delay encoded.
11
+ * The caller must pass the holder signer; neither the operator nor another holder
12
+ * can build a valid witness for this holder's share.
12
13
  */
13
14
  import { type OmniaTxDraft } from '@totemsdk/omnia';
14
15
  import type { ChainStateProvider } from '@totemsdk/chain-provider';
15
16
  import type { OmniaVtxo } from './types.js';
16
- /** State port carrying the exit delay. */
17
- export declare const EXIT_DELAY_PORT = 102;
17
+ /** State port carrying an optional exit delay on the holder's output. */
18
+ export declare const EXIT_DELAY_PORT = 110;
18
19
  export interface ExitTxParams {
19
20
  vtxo: OmniaVtxo;
20
21
  /** The pool's on-chain coin (input). */
21
22
  fundingCoinId: string;
22
23
  fundingAddress: string;
23
24
  fundingScript: string;
24
- /** Raw base-unit amount to pay the owner (the VTXO amount, pre-scaled). */
25
+ /** Total pool amount (raw base units). */
25
26
  amount: bigint;
26
- /** Exit delay carried as STATE(102). */
27
- exitDelayBlocks: number;
27
+ /** Pool holders + committed shares (aligned arrays). */
28
+ holders: string[];
29
+ shares: bigint[];
30
+ /** Index of the exiting holder (must match `vtxo.owner`). */
31
+ holderIndex: number;
32
+ /** Pool state, needed to rebuild the remainder output's state. */
33
+ root: string;
34
+ epoch: number;
35
+ exitBlock: number;
28
36
  }
29
- /** Draft that spends the pool coin to the VTXO owner with the exit delay encoded. */
37
+ /** Draft that spends the pool coin: output 0 = holder's share, remainder = pool. */
30
38
  export declare function buildExitTransactionDraft(params: ExitTxParams): OmniaTxDraft;
31
39
  export declare function exitTransactionDigest(draft: OmniaTxDraft): Uint8Array;
32
40
  export declare function exitTransactionHex(draft: OmniaTxDraft): string;
@@ -42,14 +50,13 @@ export interface ExitTxDeps {
42
50
  export interface ExitTxResult {
43
51
  vtxoId: string;
44
52
  owner: string;
45
- amount: string;
46
- exitDelayBlocks: number;
53
+ share: string;
47
54
  draftType: 'vtxo-exit';
48
55
  txHex: string;
49
56
  digest: string;
50
57
  witnessHex: string;
51
58
  txpowId?: string;
52
59
  }
53
- /** Build, sign (and optionally mine + broadcast) a VTXO exit transaction. */
60
+ /** Build, sign (and optionally mine + broadcast) a per-share VTXO exit transaction. */
54
61
  export declare function buildExitTransaction(params: ExitTxParams, deps: ExitTxDeps): Promise<ExitTxResult>;
55
62
  export {};
@@ -1,15 +1,16 @@
1
1
  "use strict";
2
2
  /**
3
- * VTXO exit transaction (RFC-038 V2).
3
+ * VTXO exit transaction (RFC-038 V3 — per-share, owner-signed).
4
4
  *
5
- * Builds a real exit transaction: spend the pool's on-chain coin and pay the
6
- * VTXO's owner, carrying the exit delay in STATE(102). The digest is signed
7
- * (injected signer) and, when a chain provider is supplied, mined + broadcast
8
- * (injected miner). This replaces the `'mock-exit'` draft with a concrete tx.
5
+ * The exit spends the pool's on-chain coin and pays the exiting holder **only
6
+ * its committed share** (`shares[holderIndex]`), returning the remainder to the
7
+ * pool with that holder's share state zeroed. It is signed with the holder's key
8
+ * — the pool script's `IF @BLOCK GT EXITBLOCK … IF SIGNEDBY(holder) … VERIFYOUT(0
9
+ * holder SHARE @TOKENID FALSE)` branch authorizes it, so the holder can broadcast
10
+ * without any operator signature or liveness, and cannot take more than its share.
9
11
  *
10
- * NOTE (RFC-038 §3.4): the full owner-unilateral covenant (a timelock branch the
11
- * owner can exercise without the operator, or a pre-signed exit tx) is a
12
- * follow-on; this builds and broadcasts the transaction with the delay encoded.
12
+ * The caller must pass the holder signer; neither the operator nor another holder
13
+ * can build a valid witness for this holder's share.
13
14
  */
14
15
  Object.defineProperty(exports, "__esModule", { value: true });
15
16
  exports.EXIT_DELAY_PORT = void 0;
@@ -20,13 +21,34 @@ exports.buildExitTransaction = buildExitTransaction;
20
21
  const omnia_1 = require("@totemsdk/omnia");
21
22
  const txpow_1 = require("@totemsdk/txpow");
22
23
  const core_1 = require("@totemsdk/core");
23
- /** State port carrying the exit delay. */
24
- exports.EXIT_DELAY_PORT = 102;
25
- /** Draft that spends the pool coin to the VTXO owner with the exit delay encoded. */
24
+ const anchor_js_1 = require("./anchor.js");
25
+ /** State port carrying an optional exit delay on the holder's output. */
26
+ exports.EXIT_DELAY_PORT = 110;
27
+ /** Draft that spends the pool coin: output 0 = holder's share, remainder = pool. */
26
28
  function buildExitTransactionDraft(params) {
27
- const stateVariables = [
28
- { port: exports.EXIT_DELAY_PORT, value: BigInt(params.exitDelayBlocks), type: 'number' },
29
- ];
29
+ const share = params.shares[params.holderIndex];
30
+ if (share === undefined)
31
+ throw new Error('exit: holderIndex out of range');
32
+ if (share <= 0n)
33
+ throw new Error('exit: holder share must be positive');
34
+ const outputs = [{
35
+ address: params.vtxo.owner,
36
+ amount: share,
37
+ tokenId: params.vtxo.tokenId,
38
+ storeState: false,
39
+ stateVariables: [],
40
+ }];
41
+ const carriesState = params.amount > share;
42
+ if (carriesState) {
43
+ const remaining = params.shares.map((s, i) => (i === params.holderIndex ? 0n : s));
44
+ outputs.push({
45
+ address: params.fundingAddress,
46
+ amount: params.amount - share,
47
+ tokenId: params.vtxo.tokenId,
48
+ storeState: true,
49
+ stateVariables: (0, anchor_js_1.poolStateVariables)(params.root, params.epoch, params.exitBlock, remaining),
50
+ });
51
+ }
30
52
  return {
31
53
  type: 'settlement',
32
54
  inputs: [{
@@ -36,15 +58,9 @@ function buildExitTransactionDraft(params) {
36
58
  tokenId: params.vtxo.tokenId,
37
59
  scriptHex: params.fundingScript,
38
60
  }],
39
- outputs: [{
40
- address: params.vtxo.owner,
41
- amount: params.amount,
42
- tokenId: params.vtxo.tokenId,
43
- storeState: false,
44
- stateVariables,
45
- }],
46
- storeState: false,
47
- stateVariables,
61
+ outputs,
62
+ storeState: carriesState,
63
+ stateVariables: [],
48
64
  };
49
65
  }
50
66
  function exitTransactionDigest(draft) {
@@ -53,7 +69,7 @@ function exitTransactionDigest(draft) {
53
69
  function exitTransactionHex(draft) {
54
70
  return (0, omnia_1.serializeTxDraft)(draft);
55
71
  }
56
- /** Build, sign (and optionally mine + broadcast) a VTXO exit transaction. */
72
+ /** Build, sign (and optionally mine + broadcast) a per-share VTXO exit transaction. */
57
73
  async function buildExitTransaction(params, deps) {
58
74
  const draft = buildExitTransactionDraft(params);
59
75
  const digest = exitTransactionDigest(draft);
@@ -61,8 +77,7 @@ async function buildExitTransaction(params, deps) {
61
77
  const result = {
62
78
  vtxoId: params.vtxo.vtxoId,
63
79
  owner: params.vtxo.owner,
64
- amount: params.vtxo.amount.toString(),
65
- exitDelayBlocks: params.exitDelayBlocks,
80
+ share: params.shares[params.holderIndex].toString(),
66
81
  draftType: 'vtxo-exit',
67
82
  txHex: exitTransactionHex(draft),
68
83
  digest: Buffer.from(digest).toString('hex'),
@@ -1,19 +1,40 @@
1
1
  /**
2
- * VTXO pool on-chain script (RFC-038 V1).
2
+ * VTXO pool on-chain script (RFC-038 V3 — per-share non-custodial).
3
3
  *
4
- * The pool is an on-chain state output holding `totalCapacity`; an epoch's batch
5
- * root is committed as STATE(100) with the epoch number in STATE(101), and the
6
- * update requires a strictly-greater epoch (`ASSERT EPOCH GT PREVSTATE(101)`) so a
7
- * old root cannot be re-anchored (RFC-038 §5 "stale/rolled-back root").
4
+ * The pool is an on-chain state output holding `totalCapacity`. An epoch's batch
5
+ * root is committed as STATE(100), the epoch number in STATE(101), the absolute
6
+ * exit-eligibility block in STATE(102), and each holder's committed share in
7
+ * STATE(300+k).
8
8
  *
9
- * CUSTODY NOTE: this script is currently **operator-only** (`SIGNEDBY(operator)`),
10
- * so until V2 lands the owner-unilateral timelock branch the pool output is
11
- * custodied by the operator on-chain. This is surfaced in RFC-038 §3.3/§5.
9
+ * Custody (RFC-039 / CUSTODY-POLICY §2):
10
+ * - **Per-share unilateral exit.** After `@BLOCK GT EXITBLOCK` each holder may
11
+ * withdraw **only its own committed share** (`STATE(300+k)`), to its own key,
12
+ * zeroing that share and returning the remainder to the pool. A holder cannot
13
+ * pay itself more than its share, so one holder can never rug the others.
14
+ * - **Cooperative update.** Before the exit window the pool may only move by
15
+ * `MULTISIG(operator + all holders)`, epoch-monotone (`ASSERT EPOCH GT
16
+ * PREVSTATE(101)`), with the exit block immutable (`ASSERT SAMESTATE(102
17
+ * 102)`) so no one can push the escape clock, and value confined to the same
18
+ * script address (`ASSERT VERIFYOUT(@INPUT @ADDRESS @AMOUNT @TOKENID TRUE)`)
19
+ * so the operator can never pay itself.
20
+ *
21
+ * The operator holds no unilateral path: it cannot move funds, cannot advance the
22
+ * exit block, and cannot stop a holder's exit. It is a liveness-only peer.
12
23
  */
13
- /** Operator-controlled, epoch-monotone state-carrying pool script. */
14
- export declare function buildPoolScript(operatorPublicKeyDigest: string): string;
24
+ /** State port base for per-holder shares: holder k ⇒ port 200+k (ports are bytes, ≤255). */
25
+ export declare const POOL_SHARE_PORT_BASE = 200;
26
+ export interface PoolScriptParams {
27
+ /** Operator WOTS public-key digest (64 hex). */
28
+ operatorPublicKeyDigest: string;
29
+ /** Holder WOTS public-key digests (64 hex), one per beneficial owner. */
30
+ holders: string[];
31
+ /** Absolute block height after which holders may exit unilaterally. */
32
+ exitBlock: number;
33
+ }
34
+ /** Per-share-constrained, epoch-monotone, state-carrying pool script. */
35
+ export declare function buildPoolScript(params: PoolScriptParams): string;
15
36
  export declare function poolScriptAddress(script: string): string;
16
- export declare function buildAndHashPoolScript(operatorPublicKeyDigest: string): {
37
+ export declare function buildAndHashPoolScript(params: PoolScriptParams): {
17
38
  script: string;
18
39
  address: string;
19
40
  };
@@ -1,41 +1,89 @@
1
1
  "use strict";
2
2
  /**
3
- * VTXO pool on-chain script (RFC-038 V1).
3
+ * VTXO pool on-chain script (RFC-038 V3 — per-share non-custodial).
4
4
  *
5
- * The pool is an on-chain state output holding `totalCapacity`; an epoch's batch
6
- * root is committed as STATE(100) with the epoch number in STATE(101), and the
7
- * update requires a strictly-greater epoch (`ASSERT EPOCH GT PREVSTATE(101)`) so a
8
- * old root cannot be re-anchored (RFC-038 §5 "stale/rolled-back root").
5
+ * The pool is an on-chain state output holding `totalCapacity`. An epoch's batch
6
+ * root is committed as STATE(100), the epoch number in STATE(101), the absolute
7
+ * exit-eligibility block in STATE(102), and each holder's committed share in
8
+ * STATE(300+k).
9
9
  *
10
- * CUSTODY NOTE: this script is currently **operator-only** (`SIGNEDBY(operator)`),
11
- * so until V2 lands the owner-unilateral timelock branch the pool output is
12
- * custodied by the operator on-chain. This is surfaced in RFC-038 §3.3/§5.
10
+ * Custody (RFC-039 / CUSTODY-POLICY §2):
11
+ * - **Per-share unilateral exit.** After `@BLOCK GT EXITBLOCK` each holder may
12
+ * withdraw **only its own committed share** (`STATE(300+k)`), to its own key,
13
+ * zeroing that share and returning the remainder to the pool. A holder cannot
14
+ * pay itself more than its share, so one holder can never rug the others.
15
+ * - **Cooperative update.** Before the exit window the pool may only move by
16
+ * `MULTISIG(operator + all holders)`, epoch-monotone (`ASSERT EPOCH GT
17
+ * PREVSTATE(101)`), with the exit block immutable (`ASSERT SAMESTATE(102
18
+ * 102)`) so no one can push the escape clock, and value confined to the same
19
+ * script address (`ASSERT VERIFYOUT(@INPUT @ADDRESS @AMOUNT @TOKENID TRUE)`)
20
+ * so the operator can never pay itself.
21
+ *
22
+ * The operator holds no unilateral path: it cannot move funds, cannot advance the
23
+ * exit block, and cannot stop a holder's exit. It is a liveness-only peer.
13
24
  */
14
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.POOL_SHARE_PORT_BASE = void 0;
15
27
  exports.buildPoolScript = buildPoolScript;
16
28
  exports.poolScriptAddress = poolScriptAddress;
17
29
  exports.buildAndHashPoolScript = buildAndHashPoolScript;
18
30
  const core_1 = require("@totemsdk/core");
19
- function operatorPkHex(operatorPublicKeyDigest) {
20
- const raw = operatorPublicKeyDigest.replace(/^0x/i, '');
31
+ /** State port base for per-holder shares: holder k ⇒ port 200+k (ports are bytes, ≤255). */
32
+ exports.POOL_SHARE_PORT_BASE = 200;
33
+ function pkHex(digest, label) {
34
+ const raw = digest.replace(/^0x/i, '');
21
35
  if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
22
- throw new Error(`operator public key digest must be 64 hex chars, got ${JSON.stringify(operatorPublicKeyDigest)}`);
36
+ throw new Error(`${label} public key digest must be 64 hex chars, got ${JSON.stringify(digest)}`);
23
37
  }
24
38
  return '0X' + raw.toUpperCase();
25
39
  }
26
- /** Operator-controlled, epoch-monotone state-carrying pool script. */
27
- function buildPoolScript(operatorPublicKeyDigest) {
28
- return [
29
- 'LET ROOT=STATE(100)',
40
+ /** Per-share-constrained, epoch-monotone, state-carrying pool script. */
41
+ function buildPoolScript(params) {
42
+ const operator = pkHex(params.operatorPublicKeyDigest, 'operator');
43
+ if (!Array.isArray(params.holders) || params.holders.length < 1) {
44
+ throw new Error('pool requires at least one holder');
45
+ }
46
+ const holders = params.holders.map((h, i) => pkHex(h, `holder[${i}]`));
47
+ if (!Number.isInteger(params.exitBlock) || params.exitBlock < 0) {
48
+ throw new Error(`exitBlock must be a non-negative integer, got ${JSON.stringify(params.exitBlock)}`);
49
+ }
50
+ const lines = [
30
51
  'LET EPOCH=STATE(101)',
31
- 'ASSERT EPOCH GT PREVSTATE(101)',
32
- `RETURN SIGNEDBY(${operatorPkHex(operatorPublicKeyDigest)})`,
33
- ].join('\n');
52
+ 'LET EXITBLOCK=STATE(102)',
53
+ 'IF @BLOCK GT EXITBLOCK THEN',
54
+ ];
55
+ holders.forEach((holder, k) => {
56
+ const port = exports.POOL_SHARE_PORT_BASE + k;
57
+ lines.push(` LET SHARE${k}=STATE(${port})`);
58
+ lines.push(` IF SIGNEDBY(${holder}) THEN`);
59
+ lines.push(` IF SHARE${k} GT 0 THEN`);
60
+ lines.push(` ASSERT VERIFYOUT(0 ${holder} SHARE${k} @TOKENID FALSE)`);
61
+ for (let j = 0; j < holders.length; j++) {
62
+ if (j === k)
63
+ continue;
64
+ lines.push(` ASSERT STATE(${exports.POOL_SHARE_PORT_BASE + j}) EQ PREVSTATE(${exports.POOL_SHARE_PORT_BASE + j})`);
65
+ }
66
+ lines.push(` ASSERT STATE(${port}) EQ 0`);
67
+ // Remainder (other holders' shares) stays in the pool; skip it when the
68
+ // withdrawing holder's share is the whole pool (no room for another output).
69
+ lines.push(` IF @AMOUNT GT SHARE${k} THEN`);
70
+ lines.push(` RETURN VERIFYOUT(@INPUT @ADDRESS @AMOUNT SUB SHARE${k} @TOKENID TRUE)`);
71
+ lines.push(' ENDIF');
72
+ lines.push(' RETURN TRUE');
73
+ lines.push(' ENDIF');
74
+ lines.push(' ENDIF');
75
+ });
76
+ lines.push('ENDIF');
77
+ lines.push('ASSERT EPOCH GT PREVSTATE(101)');
78
+ lines.push('ASSERT SAMESTATE(102 102)');
79
+ lines.push('ASSERT VERIFYOUT(@INPUT @ADDRESS @AMOUNT @TOKENID TRUE)');
80
+ lines.push(`RETURN MULTISIG(${holders.length + 1} ${operator} ${holders.join(' ')})`);
81
+ return lines.join('\n');
34
82
  }
35
83
  function poolScriptAddress(script) {
36
84
  return (0, core_1.computeScriptAddress)(script);
37
85
  }
38
- function buildAndHashPoolScript(operatorPublicKeyDigest) {
39
- const script = buildPoolScript(operatorPublicKeyDigest);
86
+ function buildAndHashPoolScript(params) {
87
+ const script = buildPoolScript(params);
40
88
  return { script, address: poolScriptAddress(script) };
41
89
  }
package/dist/exit-tx.d.ts CHANGED
@@ -1,32 +1,40 @@
1
1
  /**
2
- * VTXO exit transaction (RFC-038 V2).
2
+ * VTXO exit transaction (RFC-038 V3 — per-share, owner-signed).
3
3
  *
4
- * Builds a real exit transaction: spend the pool's on-chain coin and pay the
5
- * VTXO's owner, carrying the exit delay in STATE(102). The digest is signed
6
- * (injected signer) and, when a chain provider is supplied, mined + broadcast
7
- * (injected miner). This replaces the `'mock-exit'` draft with a concrete tx.
4
+ * The exit spends the pool's on-chain coin and pays the exiting holder **only
5
+ * its committed share** (`shares[holderIndex]`), returning the remainder to the
6
+ * pool with that holder's share state zeroed. It is signed with the holder's key
7
+ * — the pool script's `IF @BLOCK GT EXITBLOCK … IF SIGNEDBY(holder) … VERIFYOUT(0
8
+ * holder SHARE @TOKENID FALSE)` branch authorizes it, so the holder can broadcast
9
+ * without any operator signature or liveness, and cannot take more than its share.
8
10
  *
9
- * NOTE (RFC-038 §3.4): the full owner-unilateral covenant (a timelock branch the
10
- * owner can exercise without the operator, or a pre-signed exit tx) is a
11
- * follow-on; this builds and broadcasts the transaction with the delay encoded.
11
+ * The caller must pass the holder signer; neither the operator nor another holder
12
+ * can build a valid witness for this holder's share.
12
13
  */
13
14
  import { type OmniaTxDraft } from '@totemsdk/omnia';
14
15
  import type { ChainStateProvider } from '@totemsdk/chain-provider';
15
16
  import type { OmniaVtxo } from './types.js';
16
- /** State port carrying the exit delay. */
17
- export declare const EXIT_DELAY_PORT = 102;
17
+ /** State port carrying an optional exit delay on the holder's output. */
18
+ export declare const EXIT_DELAY_PORT = 110;
18
19
  export interface ExitTxParams {
19
20
  vtxo: OmniaVtxo;
20
21
  /** The pool's on-chain coin (input). */
21
22
  fundingCoinId: string;
22
23
  fundingAddress: string;
23
24
  fundingScript: string;
24
- /** Raw base-unit amount to pay the owner (the VTXO amount, pre-scaled). */
25
+ /** Total pool amount (raw base units). */
25
26
  amount: bigint;
26
- /** Exit delay carried as STATE(102). */
27
- exitDelayBlocks: number;
27
+ /** Pool holders + committed shares (aligned arrays). */
28
+ holders: string[];
29
+ shares: bigint[];
30
+ /** Index of the exiting holder (must match `vtxo.owner`). */
31
+ holderIndex: number;
32
+ /** Pool state, needed to rebuild the remainder output's state. */
33
+ root: string;
34
+ epoch: number;
35
+ exitBlock: number;
28
36
  }
29
- /** Draft that spends the pool coin to the VTXO owner with the exit delay encoded. */
37
+ /** Draft that spends the pool coin: output 0 = holder's share, remainder = pool. */
30
38
  export declare function buildExitTransactionDraft(params: ExitTxParams): OmniaTxDraft;
31
39
  export declare function exitTransactionDigest(draft: OmniaTxDraft): Uint8Array;
32
40
  export declare function exitTransactionHex(draft: OmniaTxDraft): string;
@@ -42,14 +50,13 @@ export interface ExitTxDeps {
42
50
  export interface ExitTxResult {
43
51
  vtxoId: string;
44
52
  owner: string;
45
- amount: string;
46
- exitDelayBlocks: number;
53
+ share: string;
47
54
  draftType: 'vtxo-exit';
48
55
  txHex: string;
49
56
  digest: string;
50
57
  witnessHex: string;
51
58
  txpowId?: string;
52
59
  }
53
- /** Build, sign (and optionally mine + broadcast) a VTXO exit transaction. */
60
+ /** Build, sign (and optionally mine + broadcast) a per-share VTXO exit transaction. */
54
61
  export declare function buildExitTransaction(params: ExitTxParams, deps: ExitTxDeps): Promise<ExitTxResult>;
55
62
  export {};
package/dist/exit-tx.js CHANGED
@@ -1,25 +1,47 @@
1
1
  /**
2
- * VTXO exit transaction (RFC-038 V2).
2
+ * VTXO exit transaction (RFC-038 V3 — per-share, owner-signed).
3
3
  *
4
- * Builds a real exit transaction: spend the pool's on-chain coin and pay the
5
- * VTXO's owner, carrying the exit delay in STATE(102). The digest is signed
6
- * (injected signer) and, when a chain provider is supplied, mined + broadcast
7
- * (injected miner). This replaces the `'mock-exit'` draft with a concrete tx.
4
+ * The exit spends the pool's on-chain coin and pays the exiting holder **only
5
+ * its committed share** (`shares[holderIndex]`), returning the remainder to the
6
+ * pool with that holder's share state zeroed. It is signed with the holder's key
7
+ * — the pool script's `IF @BLOCK GT EXITBLOCK … IF SIGNEDBY(holder) … VERIFYOUT(0
8
+ * holder SHARE @TOKENID FALSE)` branch authorizes it, so the holder can broadcast
9
+ * without any operator signature or liveness, and cannot take more than its share.
8
10
  *
9
- * NOTE (RFC-038 §3.4): the full owner-unilateral covenant (a timelock branch the
10
- * owner can exercise without the operator, or a pre-signed exit tx) is a
11
- * follow-on; this builds and broadcasts the transaction with the delay encoded.
11
+ * The caller must pass the holder signer; neither the operator nor another holder
12
+ * can build a valid witness for this holder's share.
12
13
  */
13
14
  import { computeOmniaTxDigest, omniaDraftToMinimaBytes, serializeTxDraft, } from '@totemsdk/omnia';
14
15
  import { serializeTxBody, mineTxPoW, TX_POW_MIN_DIFFICULTY } from '@totemsdk/txpow';
15
16
  import { concatBytes } from '@totemsdk/core';
16
- /** State port carrying the exit delay. */
17
- export const EXIT_DELAY_PORT = 102;
18
- /** Draft that spends the pool coin to the VTXO owner with the exit delay encoded. */
17
+ import { poolStateVariables } from './anchor.js';
18
+ /** State port carrying an optional exit delay on the holder's output. */
19
+ export const EXIT_DELAY_PORT = 110;
20
+ /** Draft that spends the pool coin: output 0 = holder's share, remainder = pool. */
19
21
  export function buildExitTransactionDraft(params) {
20
- const stateVariables = [
21
- { port: EXIT_DELAY_PORT, value: BigInt(params.exitDelayBlocks), type: 'number' },
22
- ];
22
+ const share = params.shares[params.holderIndex];
23
+ if (share === undefined)
24
+ throw new Error('exit: holderIndex out of range');
25
+ if (share <= 0n)
26
+ throw new Error('exit: holder share must be positive');
27
+ const outputs = [{
28
+ address: params.vtxo.owner,
29
+ amount: share,
30
+ tokenId: params.vtxo.tokenId,
31
+ storeState: false,
32
+ stateVariables: [],
33
+ }];
34
+ const carriesState = params.amount > share;
35
+ if (carriesState) {
36
+ const remaining = params.shares.map((s, i) => (i === params.holderIndex ? 0n : s));
37
+ outputs.push({
38
+ address: params.fundingAddress,
39
+ amount: params.amount - share,
40
+ tokenId: params.vtxo.tokenId,
41
+ storeState: true,
42
+ stateVariables: poolStateVariables(params.root, params.epoch, params.exitBlock, remaining),
43
+ });
44
+ }
23
45
  return {
24
46
  type: 'settlement',
25
47
  inputs: [{
@@ -29,15 +51,9 @@ export function buildExitTransactionDraft(params) {
29
51
  tokenId: params.vtxo.tokenId,
30
52
  scriptHex: params.fundingScript,
31
53
  }],
32
- outputs: [{
33
- address: params.vtxo.owner,
34
- amount: params.amount,
35
- tokenId: params.vtxo.tokenId,
36
- storeState: false,
37
- stateVariables,
38
- }],
39
- storeState: false,
40
- stateVariables,
54
+ outputs,
55
+ storeState: carriesState,
56
+ stateVariables: [],
41
57
  };
42
58
  }
43
59
  export function exitTransactionDigest(draft) {
@@ -46,7 +62,7 @@ export function exitTransactionDigest(draft) {
46
62
  export function exitTransactionHex(draft) {
47
63
  return serializeTxDraft(draft);
48
64
  }
49
- /** Build, sign (and optionally mine + broadcast) a VTXO exit transaction. */
65
+ /** Build, sign (and optionally mine + broadcast) a per-share VTXO exit transaction. */
50
66
  export async function buildExitTransaction(params, deps) {
51
67
  const draft = buildExitTransactionDraft(params);
52
68
  const digest = exitTransactionDigest(draft);
@@ -54,8 +70,7 @@ export async function buildExitTransaction(params, deps) {
54
70
  const result = {
55
71
  vtxoId: params.vtxo.vtxoId,
56
72
  owner: params.vtxo.owner,
57
- amount: params.vtxo.amount.toString(),
58
- exitDelayBlocks: params.exitDelayBlocks,
73
+ share: params.shares[params.holderIndex].toString(),
59
74
  draftType: 'vtxo-exit',
60
75
  txHex: exitTransactionHex(draft),
61
76
  digest: Buffer.from(digest).toString('hex'),
@@ -1,19 +1,40 @@
1
1
  /**
2
- * VTXO pool on-chain script (RFC-038 V1).
2
+ * VTXO pool on-chain script (RFC-038 V3 — per-share non-custodial).
3
3
  *
4
- * The pool is an on-chain state output holding `totalCapacity`; an epoch's batch
5
- * root is committed as STATE(100) with the epoch number in STATE(101), and the
6
- * update requires a strictly-greater epoch (`ASSERT EPOCH GT PREVSTATE(101)`) so a
7
- * old root cannot be re-anchored (RFC-038 §5 "stale/rolled-back root").
4
+ * The pool is an on-chain state output holding `totalCapacity`. An epoch's batch
5
+ * root is committed as STATE(100), the epoch number in STATE(101), the absolute
6
+ * exit-eligibility block in STATE(102), and each holder's committed share in
7
+ * STATE(300+k).
8
8
  *
9
- * CUSTODY NOTE: this script is currently **operator-only** (`SIGNEDBY(operator)`),
10
- * so until V2 lands the owner-unilateral timelock branch the pool output is
11
- * custodied by the operator on-chain. This is surfaced in RFC-038 §3.3/§5.
9
+ * Custody (RFC-039 / CUSTODY-POLICY §2):
10
+ * - **Per-share unilateral exit.** After `@BLOCK GT EXITBLOCK` each holder may
11
+ * withdraw **only its own committed share** (`STATE(300+k)`), to its own key,
12
+ * zeroing that share and returning the remainder to the pool. A holder cannot
13
+ * pay itself more than its share, so one holder can never rug the others.
14
+ * - **Cooperative update.** Before the exit window the pool may only move by
15
+ * `MULTISIG(operator + all holders)`, epoch-monotone (`ASSERT EPOCH GT
16
+ * PREVSTATE(101)`), with the exit block immutable (`ASSERT SAMESTATE(102
17
+ * 102)`) so no one can push the escape clock, and value confined to the same
18
+ * script address (`ASSERT VERIFYOUT(@INPUT @ADDRESS @AMOUNT @TOKENID TRUE)`)
19
+ * so the operator can never pay itself.
20
+ *
21
+ * The operator holds no unilateral path: it cannot move funds, cannot advance the
22
+ * exit block, and cannot stop a holder's exit. It is a liveness-only peer.
12
23
  */
13
- /** Operator-controlled, epoch-monotone state-carrying pool script. */
14
- export declare function buildPoolScript(operatorPublicKeyDigest: string): string;
24
+ /** State port base for per-holder shares: holder k ⇒ port 200+k (ports are bytes, ≤255). */
25
+ export declare const POOL_SHARE_PORT_BASE = 200;
26
+ export interface PoolScriptParams {
27
+ /** Operator WOTS public-key digest (64 hex). */
28
+ operatorPublicKeyDigest: string;
29
+ /** Holder WOTS public-key digests (64 hex), one per beneficial owner. */
30
+ holders: string[];
31
+ /** Absolute block height after which holders may exit unilaterally. */
32
+ exitBlock: number;
33
+ }
34
+ /** Per-share-constrained, epoch-monotone, state-carrying pool script. */
35
+ export declare function buildPoolScript(params: PoolScriptParams): string;
15
36
  export declare function poolScriptAddress(script: string): string;
16
- export declare function buildAndHashPoolScript(operatorPublicKeyDigest: string): {
37
+ export declare function buildAndHashPoolScript(params: PoolScriptParams): {
17
38
  script: string;
18
39
  address: string;
19
40
  };
@@ -1,36 +1,83 @@
1
1
  /**
2
- * VTXO pool on-chain script (RFC-038 V1).
2
+ * VTXO pool on-chain script (RFC-038 V3 — per-share non-custodial).
3
3
  *
4
- * The pool is an on-chain state output holding `totalCapacity`; an epoch's batch
5
- * root is committed as STATE(100) with the epoch number in STATE(101), and the
6
- * update requires a strictly-greater epoch (`ASSERT EPOCH GT PREVSTATE(101)`) so a
7
- * old root cannot be re-anchored (RFC-038 §5 "stale/rolled-back root").
4
+ * The pool is an on-chain state output holding `totalCapacity`. An epoch's batch
5
+ * root is committed as STATE(100), the epoch number in STATE(101), the absolute
6
+ * exit-eligibility block in STATE(102), and each holder's committed share in
7
+ * STATE(300+k).
8
8
  *
9
- * CUSTODY NOTE: this script is currently **operator-only** (`SIGNEDBY(operator)`),
10
- * so until V2 lands the owner-unilateral timelock branch the pool output is
11
- * custodied by the operator on-chain. This is surfaced in RFC-038 §3.3/§5.
9
+ * Custody (RFC-039 / CUSTODY-POLICY §2):
10
+ * - **Per-share unilateral exit.** After `@BLOCK GT EXITBLOCK` each holder may
11
+ * withdraw **only its own committed share** (`STATE(300+k)`), to its own key,
12
+ * zeroing that share and returning the remainder to the pool. A holder cannot
13
+ * pay itself more than its share, so one holder can never rug the others.
14
+ * - **Cooperative update.** Before the exit window the pool may only move by
15
+ * `MULTISIG(operator + all holders)`, epoch-monotone (`ASSERT EPOCH GT
16
+ * PREVSTATE(101)`), with the exit block immutable (`ASSERT SAMESTATE(102
17
+ * 102)`) so no one can push the escape clock, and value confined to the same
18
+ * script address (`ASSERT VERIFYOUT(@INPUT @ADDRESS @AMOUNT @TOKENID TRUE)`)
19
+ * so the operator can never pay itself.
20
+ *
21
+ * The operator holds no unilateral path: it cannot move funds, cannot advance the
22
+ * exit block, and cannot stop a holder's exit. It is a liveness-only peer.
12
23
  */
13
24
  import { computeScriptAddress } from '@totemsdk/core';
14
- function operatorPkHex(operatorPublicKeyDigest) {
15
- const raw = operatorPublicKeyDigest.replace(/^0x/i, '');
25
+ /** State port base for per-holder shares: holder k ⇒ port 200+k (ports are bytes, ≤255). */
26
+ export const POOL_SHARE_PORT_BASE = 200;
27
+ function pkHex(digest, label) {
28
+ const raw = digest.replace(/^0x/i, '');
16
29
  if (!/^[0-9a-fA-F]{64}$/.test(raw)) {
17
- throw new Error(`operator public key digest must be 64 hex chars, got ${JSON.stringify(operatorPublicKeyDigest)}`);
30
+ throw new Error(`${label} public key digest must be 64 hex chars, got ${JSON.stringify(digest)}`);
18
31
  }
19
32
  return '0X' + raw.toUpperCase();
20
33
  }
21
- /** Operator-controlled, epoch-monotone state-carrying pool script. */
22
- export function buildPoolScript(operatorPublicKeyDigest) {
23
- return [
24
- 'LET ROOT=STATE(100)',
34
+ /** Per-share-constrained, epoch-monotone, state-carrying pool script. */
35
+ export function buildPoolScript(params) {
36
+ const operator = pkHex(params.operatorPublicKeyDigest, 'operator');
37
+ if (!Array.isArray(params.holders) || params.holders.length < 1) {
38
+ throw new Error('pool requires at least one holder');
39
+ }
40
+ const holders = params.holders.map((h, i) => pkHex(h, `holder[${i}]`));
41
+ if (!Number.isInteger(params.exitBlock) || params.exitBlock < 0) {
42
+ throw new Error(`exitBlock must be a non-negative integer, got ${JSON.stringify(params.exitBlock)}`);
43
+ }
44
+ const lines = [
25
45
  'LET EPOCH=STATE(101)',
26
- 'ASSERT EPOCH GT PREVSTATE(101)',
27
- `RETURN SIGNEDBY(${operatorPkHex(operatorPublicKeyDigest)})`,
28
- ].join('\n');
46
+ 'LET EXITBLOCK=STATE(102)',
47
+ 'IF @BLOCK GT EXITBLOCK THEN',
48
+ ];
49
+ holders.forEach((holder, k) => {
50
+ const port = POOL_SHARE_PORT_BASE + k;
51
+ lines.push(` LET SHARE${k}=STATE(${port})`);
52
+ lines.push(` IF SIGNEDBY(${holder}) THEN`);
53
+ lines.push(` IF SHARE${k} GT 0 THEN`);
54
+ lines.push(` ASSERT VERIFYOUT(0 ${holder} SHARE${k} @TOKENID FALSE)`);
55
+ for (let j = 0; j < holders.length; j++) {
56
+ if (j === k)
57
+ continue;
58
+ lines.push(` ASSERT STATE(${POOL_SHARE_PORT_BASE + j}) EQ PREVSTATE(${POOL_SHARE_PORT_BASE + j})`);
59
+ }
60
+ lines.push(` ASSERT STATE(${port}) EQ 0`);
61
+ // Remainder (other holders' shares) stays in the pool; skip it when the
62
+ // withdrawing holder's share is the whole pool (no room for another output).
63
+ lines.push(` IF @AMOUNT GT SHARE${k} THEN`);
64
+ lines.push(` RETURN VERIFYOUT(@INPUT @ADDRESS @AMOUNT SUB SHARE${k} @TOKENID TRUE)`);
65
+ lines.push(' ENDIF');
66
+ lines.push(' RETURN TRUE');
67
+ lines.push(' ENDIF');
68
+ lines.push(' ENDIF');
69
+ });
70
+ lines.push('ENDIF');
71
+ lines.push('ASSERT EPOCH GT PREVSTATE(101)');
72
+ lines.push('ASSERT SAMESTATE(102 102)');
73
+ lines.push('ASSERT VERIFYOUT(@INPUT @ADDRESS @AMOUNT @TOKENID TRUE)');
74
+ lines.push(`RETURN MULTISIG(${holders.length + 1} ${operator} ${holders.join(' ')})`);
75
+ return lines.join('\n');
29
76
  }
30
77
  export function poolScriptAddress(script) {
31
78
  return computeScriptAddress(script);
32
79
  }
33
- export function buildAndHashPoolScript(operatorPublicKeyDigest) {
34
- const script = buildPoolScript(operatorPublicKeyDigest);
80
+ export function buildAndHashPoolScript(params) {
81
+ const script = buildPoolScript(params);
35
82
  return { script, address: poolScriptAddress(script) };
36
83
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totemsdk/omnia-vtxo",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Virtual UTXO / payment-pool claim layer for Omnia — cash-like off-chain balance primitive for Totem Edge",
5
5
  "main": "dist/cjs/index.js",
6
6
  "types": "dist/cjs/index.d.ts",
@@ -25,11 +25,11 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "@totemsdk/chain-provider": "^0.2.1",
28
- "@totemsdk/omnia": "^1.0.3",
29
- "@totemsdk/core": "^1.2.11",
28
+ "@totemsdk/omnia": "^2.0.1",
29
+ "@totemsdk/txpow": "^1.0.2",
30
30
  "@totemsdk/storage": "^0.1.0",
31
- "@totemsdk/wots-lease": "^1.0.1",
32
- "@totemsdk/txpow": "^1.0.2"
31
+ "@totemsdk/core": "^1.2.11",
32
+ "@totemsdk/wots-lease": "^1.0.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/jest": "^30.0.0",