ecash-lib 4.7.0 → 4.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.
package/src/index.ts CHANGED
@@ -31,4 +31,5 @@ export * from './token/empp.js';
31
31
  export * from './token/slp.js';
32
32
  export * from './token/slp.parse.js';
33
33
  export * from './parse/opreturn.js';
34
+ export * from './inputData.js';
34
35
  export * as payment from './payment';
@@ -0,0 +1,109 @@
1
+ // Copyright (c) 2026 The Bitcoin developers
2
+ // Distributed under the MIT software license, see the accompanying
3
+ // file COPYING or http://www.opensource.org/licenses/mit-license.php.
4
+
5
+ import { Bytes } from './io/bytes.js';
6
+ import { readOp, isPushOp } from './op.js';
7
+ import { shaRmd160 } from './hash.js';
8
+ import { Address } from './address/address.js';
9
+ import { OP_CHECKSIGVERIFY, OP_EQUAL, OP_EQUALVERIFY } from './opcode.js';
10
+
11
+ /**
12
+ * Result of decoding a P2SH input that uses the Agora-style input-data format.
13
+ *
14
+ * Format follows ecash-agora ad script (ad.ts parseAdScriptSig, partial.ts adScript):
15
+ * - scriptSig = <lokad> <data> <sig> <pubkey> <redeemScript>
16
+ * - lokad first for chronik lokadId indexing
17
+ * - Redeem script: OP_CHECKSIGVERIFY pushBytes(data) OP_EQUALVERIFY pushBytes(lokad) OP_EQUAL
18
+ */
19
+ export interface DecodedInputData {
20
+ /** 4-byte lokad ID (for chronik indexing) */
21
+ lokadId: Uint8Array;
22
+ /** Arbitrary data bytes */
23
+ data: Uint8Array;
24
+ /** Sender's P2PKH address (from pubkey in scriptSig) */
25
+ address: string;
26
+ }
27
+
28
+ /**
29
+ * Decode scriptSig from a P2SH input that uses the Agora-style input-data format.
30
+ *
31
+ * Built: scriptSig = [lokad, data, sig, pubkey, redeemScript]
32
+ * Redeem script: OP_CHECKSIG pushBytes(data) OP_EQUALVERIFY pushBytes(lokad) OP_EQUAL
33
+ *
34
+ * @param scriptSig - The scriptSig bytes from the spending input
35
+ * @param prefix - Address prefix (default 'ecash')
36
+ * @returns Decoded lokadId, data, and address, or undefined if the format doesn't match
37
+ */
38
+ export function decodeInputData(
39
+ scriptSig: Uint8Array,
40
+ prefix = 'ecash',
41
+ ): DecodedInputData | undefined {
42
+ const pushes: Uint8Array[] = [];
43
+ const bytes = new Bytes(scriptSig);
44
+
45
+ while (bytes.data.length - bytes.idx > 0) {
46
+ try {
47
+ const op = readOp(bytes);
48
+ if (isPushOp(op)) {
49
+ pushes.push(op.data);
50
+ }
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ // scriptSig = <lokad> <data> <sig> <pubkey> <redeemScript>
57
+ if (pushes.length < 5) {
58
+ return undefined;
59
+ }
60
+ const lokadId = pushes[0];
61
+ if (lokadId.length !== 4) {
62
+ return undefined;
63
+ }
64
+ const data = pushes[1];
65
+ const pubkey = pushes[pushes.length - 2];
66
+ const redeemScript = pushes[pushes.length - 1];
67
+
68
+ if (pubkey.length !== 33 && pubkey.length !== 65) {
69
+ return undefined;
70
+ }
71
+
72
+ // Redeem script: OP_CHECKSIGVERIFY pushBytes(data) OP_EQUALVERIFY pushBytes(lokad) OP_EQUAL
73
+ const redeemBytes = new Bytes(redeemScript);
74
+ try {
75
+ if (readOp(redeemBytes) !== OP_CHECKSIGVERIFY) {
76
+ return undefined;
77
+ }
78
+ const dataOp = readOp(redeemBytes);
79
+ if (!isPushOp(dataOp)) {
80
+ return undefined;
81
+ }
82
+ if (
83
+ dataOp.data.length !== data.length ||
84
+ dataOp.data.some((b, i) => b !== data[i])
85
+ ) {
86
+ return undefined;
87
+ }
88
+ if (readOp(redeemBytes) !== OP_EQUALVERIFY) {
89
+ return undefined;
90
+ }
91
+ const lokadOp = readOp(redeemBytes);
92
+ if (!isPushOp(lokadOp) || lokadOp.data.length !== 4) {
93
+ return undefined;
94
+ }
95
+ if (lokadOp.data.some((b, i) => b !== lokadId[i])) {
96
+ return undefined;
97
+ }
98
+ if (readOp(redeemBytes) !== OP_EQUAL) {
99
+ return undefined;
100
+ }
101
+ } catch {
102
+ return undefined;
103
+ }
104
+
105
+ const pkh = shaRmd160(pubkey);
106
+ const address = Address.p2pkh(pkh, prefix).toString();
107
+
108
+ return { lokadId, data, address };
109
+ }
package/src/op.ts CHANGED
@@ -2,11 +2,14 @@
2
2
  // Distributed under the MIT software license, see the accompanying
3
3
  // file COPYING or http://www.opensource.org/licenses/mit-license.php.
4
4
 
5
+ import { MAX_SCRIPTNUM_BYTE_SIZE } from './consts.js';
5
6
  import { Bytes } from './io/bytes.js';
6
7
  import { Writer } from './io/writer.js';
7
8
  import {
8
9
  OP_0,
10
+ OP_1,
9
11
  OP_1NEGATE,
12
+ OP_16,
10
13
  OP_PUSHDATA1,
11
14
  OP_PUSHDATA2,
12
15
  OP_PUSHDATA4,
@@ -28,6 +31,106 @@ export interface PushOp {
28
31
  data: Uint8Array;
29
32
  }
30
33
 
34
+ /**
35
+ * Check if bytes are a minimally encoded script number (not arbitrary data).
36
+ * See CScriptNum::IsMinimallyEncoded in script/script.cpp.
37
+ */
38
+ function isMinimallyEncoded(data: Uint8Array): boolean {
39
+ if (data.length === 0) return true;
40
+ // Check that the number is encoded with the minimum possible number of bytes.
41
+ // If the most-significant-byte (excluding the sign bit) is zero then we're not minimal.
42
+ // This also rejects the negative-zero encoding, 0x80.
43
+ const last = data[data.length - 1]!;
44
+ if ((last & 0x7f) === 0) {
45
+ // One exception: if there's more than one byte and the most significant bit of the
46
+ // second-to-last byte is set, it would conflict with the sign bit. E.g. +-255
47
+ // encode to 0xff00 and 0xff80 respectively.
48
+ if (data.length <= 1 || (data[data.length - 2]! & 0x80) === 0) {
49
+ return false;
50
+ }
51
+ }
52
+ return true;
53
+ }
54
+
55
+ /**
56
+ * Decode a minimally-encoded script number from bytes (little-endian, sign-magnitude).
57
+ * Validates size and minimal encoding; callers can rely on returned value being valid.
58
+ * Always returns bigint for type safety and full 64-bit range (matches CScriptNum::getint).
59
+ * Empty byte array decodes to 0.
60
+ * @throws Error if data exceeds max size or is not minimally encoded
61
+ */
62
+ function decodeScriptNum(data: Uint8Array): bigint {
63
+ if (data.length === 0) {
64
+ return 0n;
65
+ }
66
+ if (data.length > MAX_SCRIPTNUM_BYTE_SIZE) {
67
+ throw new Error(
68
+ `Script number exceeds maximum size (${data.length} > ${MAX_SCRIPTNUM_BYTE_SIZE} bytes)`,
69
+ );
70
+ }
71
+ if (!isMinimallyEncoded(data)) {
72
+ throw new Error('Script number is not minimally encoded');
73
+ }
74
+ let result = 0n;
75
+ for (let i = 0; i < data.length; i++) {
76
+ result |= BigInt(data[i]!) << BigInt(8 * i);
77
+ }
78
+ if (data[data.length - 1]! & 0x80) {
79
+ const mask = ~(0x80n << BigInt(8 * (data.length - 1)));
80
+ result &= mask;
81
+ result = -result;
82
+ }
83
+ return result;
84
+ }
85
+
86
+ /**
87
+ * Check if the PushOp uses minimal push encoding for its data length.
88
+ * pushNumberOp always produces minimal pushes.
89
+ */
90
+ function isMinimalPushOp(pushOp: PushOp): boolean {
91
+ const len = pushOp.data.length;
92
+ if (len === 0) return pushOp.opcode === OP_0;
93
+ if (len >= 1 && len <= 0x4b) return pushOp.opcode === len;
94
+ if (len >= 0x4c && len <= 0xff) return pushOp.opcode === OP_PUSHDATA1;
95
+ if (len >= 0x100 && len <= 0xffff) return pushOp.opcode === OP_PUSHDATA2;
96
+ if (len >= 0x10000 && len <= 0xffffffff)
97
+ return pushOp.opcode === OP_PUSHDATA4;
98
+ return false;
99
+ }
100
+
101
+ /**
102
+ * Parse a number from a script op.
103
+ * Inverse of pushNumberOp: handles OP_0 (0), OP_1NEGATE (-1), OP_1 through OP_16,
104
+ * single-byte push data, and multi-byte minimal script number encoding (up to 64-bit).
105
+ * Always returns bigint for type safety (matches CScriptNum::getint).
106
+ * @throws Error with descriptive message if the op does not encode a number
107
+ */
108
+ export function parseNumberFromOp(op: Op): bigint {
109
+ if (typeof op === 'number') {
110
+ if (op === OP_0) {
111
+ return 0n;
112
+ }
113
+ if (op === OP_1NEGATE) {
114
+ return -1n;
115
+ }
116
+ if (op >= OP_1 && op <= OP_16) {
117
+ return BigInt(op - 0x50);
118
+ }
119
+ throw new Error(
120
+ `Opcode 0x${op.toString(16)} does not encode a number (expected OP_0, OP_1NEGATE, or OP_1-OP_16)`,
121
+ );
122
+ }
123
+ if (!isPushOp(op)) {
124
+ throw new Error('Op is not a push op');
125
+ }
126
+ if (!isMinimalPushOp(op)) {
127
+ throw new Error(
128
+ `Push uses non-minimal encoding (opcode 0x${op.opcode.toString(16)} for ${op.data.length} bytes)`,
129
+ );
130
+ }
131
+ return decodeScriptNum(op.data);
132
+ }
133
+
31
134
  /** Returns true if the given object is a `PushOp` */
32
135
  export function isPushOp(op: any): op is PushOp {
33
136
  if (!op || typeof op !== 'object') {
@@ -76,6 +76,20 @@ export interface Action {
76
76
  feePerKb?: bigint;
77
77
  /** Maximum tx sersize to be used for tx(s) of this action, defaults to MAX_TX_SERSIZE */
78
78
  maxTxSersize?: number;
79
+ /**
80
+ * Data encoded in a P2SH redeem script, revealed when the input is spent.
81
+ * Creates a chained tx: prep tx sends to P2SH, main tx spends it.
82
+ * Works for any tx type (XEC-only, SLP, ALP). Redeem script includes
83
+ * sender pubkey, so P2PKH address derivable from the spending input.
84
+ *
85
+ * Format follows ecash-agora ad script for chronik indexing:
86
+ * - lokad: 4-byte identifier (indexed by chronik as lokadId)
87
+ * - data: arbitrary bytes (like Agora covenant consts, minus covenant variant)
88
+ *
89
+ * scriptSig = <lokad> <data> <sig> <pubkey> <redeemScript>
90
+ * Redeem script: OP_CHECKSIGVERIFY pushBytes(data) OP_EQUALVERIFY pushBytes(lokad) OP_EQUAL
91
+ */
92
+ p2shInputData?: { lokad: Uint8Array; data: Uint8Array };
79
93
  }
80
94
 
81
95
  /**
@@ -156,6 +170,7 @@ export interface BurnAction {
156
170
  /**
157
171
  * Arbitrary data push to be included for token types that support EMPP
158
172
  * Not associated with PaymentOutput[]
173
+ * Only supported for ALP_TOKEN_TYPE_STANDARD (EMPP in OP_RETURN)
159
174
  */
160
175
  export interface DataAction {
161
176
  type: 'DATA';