Skip to content

Commit 99bb080

Browse files
authored
Merge pull request #2297 from landabaso/feat/p2ms-20-keys
p2ms: support up to 20 keys
2 parents 13aea8c + 16f1bac commit 99bb080

File tree

5 files changed

+190
-29
lines changed

5 files changed

+190
-29
lines changed

src/cjs/payments/p2ms.cjs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,28 @@ Object.defineProperty(exports, '__esModule', { value: true });
4747
exports.p2ms = p2ms;
4848
const networks_js_1 = require('../networks.cjs');
4949
const bscript = __importStar(require('../script.cjs'));
50+
const scriptNumber = __importStar(require('../script_number.cjs'));
5051
const types_js_1 = require('../types.cjs');
5152
const lazy = __importStar(require('./lazy.cjs'));
5253
const v = __importStar(require('valibot'));
5354
const OPS = bscript.OPS;
5455
const OP_INT_BASE = OPS.OP_RESERVED; // OP_1 - 1
56+
function encodeSmallOrScriptNum(n) {
57+
return n <= 16 ? OP_INT_BASE + n : scriptNumber.encode(n);
58+
}
59+
function decodeSmallOrScriptNum(chunk) {
60+
if (typeof chunk === 'number') {
61+
const val = chunk - OP_INT_BASE;
62+
if (val < 1 || val > 16)
63+
throw new TypeError(`Invalid opcode: expected OP_1–OP_16, got ${chunk}`);
64+
return val;
65+
} else return scriptNumber.decode(chunk);
66+
}
67+
function isSmallOrScriptNum(chunk) {
68+
if (typeof chunk === 'number')
69+
return chunk - OP_INT_BASE >= 1 && chunk - OP_INT_BASE <= 16;
70+
else return Number.isInteger(scriptNumber.decode(chunk));
71+
}
5572
// input: OP_0 [signatures ...]
5673
// output: m [pubKeys ...] n OP_CHECKMULTISIG
5774
/**
@@ -104,8 +121,9 @@ function p2ms(a, opts) {
104121
if (decoded) return;
105122
decoded = true;
106123
chunks = bscript.decompile(output);
107-
o.m = chunks[0] - OP_INT_BASE;
108-
o.n = chunks[chunks.length - 2] - OP_INT_BASE;
124+
if (chunks.length < 3) throw new TypeError('Output is invalid');
125+
o.m = decodeSmallOrScriptNum(chunks[0]);
126+
o.n = decodeSmallOrScriptNum(chunks[chunks.length - 2]);
109127
o.pubkeys = chunks.slice(1, -2);
110128
}
111129
lazy.prop(o, 'output', () => {
@@ -114,9 +132,9 @@ function p2ms(a, opts) {
114132
if (!a.pubkeys) return;
115133
return bscript.compile(
116134
[].concat(
117-
OP_INT_BASE + a.m,
135+
encodeSmallOrScriptNum(a.m),
118136
a.pubkeys,
119-
OP_INT_BASE + o.n,
137+
encodeSmallOrScriptNum(o.n),
120138
OPS.OP_CHECKMULTISIG,
121139
),
122140
);
@@ -155,13 +173,13 @@ function p2ms(a, opts) {
155173
if (opts.validate) {
156174
if (a.output) {
157175
decode(a.output);
158-
v.parse(v.number(), chunks[0], { message: 'Output is invalid' });
159-
v.parse(v.number(), chunks[chunks.length - 2], {
160-
message: 'Output is invalid',
161-
});
176+
if (!isSmallOrScriptNum(chunks[0]))
177+
throw new TypeError('Output is invalid');
178+
if (!isSmallOrScriptNum(chunks[chunks.length - 2]))
179+
throw new TypeError('Output is invalid');
162180
if (chunks[chunks.length - 1] !== OPS.OP_CHECKMULTISIG)
163181
throw new TypeError('Output is invalid');
164-
if (o.m <= 0 || o.n > 16 || o.m > o.n || o.n !== chunks.length - 3)
182+
if (o.m <= 0 || o.n > 20 || o.m > o.n || o.n !== chunks.length - 3)
165183
throw new TypeError('Output is invalid');
166184
if (!o.pubkeys.every(x => (0, types_js_1.isPoint)(x)))
167185
throw new TypeError('Output is invalid');

src/esm/payments/p2ms.js

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import { bitcoin as BITCOIN_NETWORK } from '../networks.js';
22
import * as bscript from '../script.js';
3+
import * as scriptNumber from '../script_number.js';
34
import { BufferSchema, isPoint, stacksEqual } from '../types.js';
45
import * as lazy from './lazy.js';
56
import * as v from 'valibot';
67
const OPS = bscript.OPS;
78
const OP_INT_BASE = OPS.OP_RESERVED; // OP_1 - 1
9+
function encodeSmallOrScriptNum(n) {
10+
return n <= 16 ? OP_INT_BASE + n : scriptNumber.encode(n);
11+
}
12+
function decodeSmallOrScriptNum(chunk) {
13+
if (typeof chunk === 'number') {
14+
const val = chunk - OP_INT_BASE;
15+
if (val < 1 || val > 16)
16+
throw new TypeError(`Invalid opcode: expected OP_1–OP_16, got ${chunk}`);
17+
return val;
18+
} else return scriptNumber.decode(chunk);
19+
}
20+
function isSmallOrScriptNum(chunk) {
21+
if (typeof chunk === 'number')
22+
return chunk - OP_INT_BASE >= 1 && chunk - OP_INT_BASE <= 16;
23+
else return Number.isInteger(scriptNumber.decode(chunk));
24+
}
825
// input: OP_0 [signatures ...]
926
// output: m [pubKeys ...] n OP_CHECKMULTISIG
1027
/**
@@ -54,8 +71,9 @@ export function p2ms(a, opts) {
5471
if (decoded) return;
5572
decoded = true;
5673
chunks = bscript.decompile(output);
57-
o.m = chunks[0] - OP_INT_BASE;
58-
o.n = chunks[chunks.length - 2] - OP_INT_BASE;
74+
if (chunks.length < 3) throw new TypeError('Output is invalid');
75+
o.m = decodeSmallOrScriptNum(chunks[0]);
76+
o.n = decodeSmallOrScriptNum(chunks[chunks.length - 2]);
5977
o.pubkeys = chunks.slice(1, -2);
6078
}
6179
lazy.prop(o, 'output', () => {
@@ -64,9 +82,9 @@ export function p2ms(a, opts) {
6482
if (!a.pubkeys) return;
6583
return bscript.compile(
6684
[].concat(
67-
OP_INT_BASE + a.m,
85+
encodeSmallOrScriptNum(a.m),
6886
a.pubkeys,
69-
OP_INT_BASE + o.n,
87+
encodeSmallOrScriptNum(o.n),
7088
OPS.OP_CHECKMULTISIG,
7189
),
7290
);
@@ -105,13 +123,13 @@ export function p2ms(a, opts) {
105123
if (opts.validate) {
106124
if (a.output) {
107125
decode(a.output);
108-
v.parse(v.number(), chunks[0], { message: 'Output is invalid' });
109-
v.parse(v.number(), chunks[chunks.length - 2], {
110-
message: 'Output is invalid',
111-
});
126+
if (!isSmallOrScriptNum(chunks[0]))
127+
throw new TypeError('Output is invalid');
128+
if (!isSmallOrScriptNum(chunks[chunks.length - 2]))
129+
throw new TypeError('Output is invalid');
112130
if (chunks[chunks.length - 1] !== OPS.OP_CHECKMULTISIG)
113131
throw new TypeError('Output is invalid');
114-
if (o.m <= 0 || o.n > 16 || o.m > o.n || o.n !== chunks.length - 3)
132+
if (o.m <= 0 || o.n > 20 || o.m > o.n || o.n !== chunks.length - 3)
115133
throw new TypeError('Output is invalid');
116134
if (!o.pubkeys.every(x => isPoint(x)))
117135
throw new TypeError('Output is invalid');

test/fixtures/p2ms.json

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,44 @@
180180
"input": "OP_0 OP_0 300602010102010001",
181181
"witness": []
182182
}
183+
},
184+
{
185+
"description": "output from output (20-of-20 multisig)",
186+
"arguments": {
187+
"output": "14 0255355ca83c973f1d97ce0e3843c85d78905af16b4dc531bc488e57212d230116 03e3e592638b492e642f8c389f9577b0809d4f73032c4c0f9981cb57cb3eebbe4c 02b0a4e912141c3b1044cdc13f196ff95c916f05f43d04184b7dcefa6977fac24a 02958361ee738c994b5e799c13c964602915eaa847ed7e5a5a3f8c42312cd39a61 0227654f4d0ddea28183970c7532692fadf8dd042e31a51c5936f85487c5a1ec02 02c38b046055858679daf9468ac44c991cce4bf91f9f8f4eab6ea7f9d2041e499f 0335676ec077b748a253dd92a1ca9387533818e511741281ebc96d61eccd86cf39 03165f2a7bbd0789c795f66ca0c383d963fa17bf4289d9faae8b8b8f098b3e669f 032c3263ced2ce21ac62ff8828f67cf12a4cc3cb93edfd432ea4a1cba2d533bae8 0304f4f8a3039ab91a1bbe211a1e16b80b549d9feffb4b83cd9e4d43d7e55964d1 03e3dfe07b7c83bdd7908795f890ba8de2117fc3303d048edd54a72de1183ed737 02ef28db8ca852fd8f871835d31a600728cf1f76744dc6b22c9d36394d146a04e5 03ce600e3f61f8b72de1715654010fa54da7a12d39b8d8cb9969f160888eaa2e0e 02df6d74c70b197cf0fc65216ab5ce25b120338a02049a45907f6e54b2e7c779b8 029f5f53b28673bb834c082f3ccd4e73c1a2099368fe7b8567cb817b7675531e26 023ccd807197e3af4139ad4647a0350bef5829f41651729defac3863e964cd3cb1 02482d77f0fb886bb23d9c431960933499982f6ecaf45f2e279203dbe642aca03b 02312b2cac8fb58150596ce11f7db6a50775d257fb8f9bdd1a3e129eef10ea182c 038c6bd3d819d30aa07cb52a2ca4aaaaf83e63bc9947a9e0230abe5233af1c12dd 038e3b9db1442165010102596f30536020e451b6e645ef1fb0c21cb965f84e3eee 14 OP_CHECKMULTISIG"
188+
},
189+
"options": {},
190+
"expected": {
191+
"m": 20,
192+
"n": 20,
193+
"name": "p2ms(20 of 20)",
194+
"output": "14 0255355ca83c973f1d97ce0e3843c85d78905af16b4dc531bc488e57212d230116 03e3e592638b492e642f8c389f9577b0809d4f73032c4c0f9981cb57cb3eebbe4c 02b0a4e912141c3b1044cdc13f196ff95c916f05f43d04184b7dcefa6977fac24a 02958361ee738c994b5e799c13c964602915eaa847ed7e5a5a3f8c42312cd39a61 0227654f4d0ddea28183970c7532692fadf8dd042e31a51c5936f85487c5a1ec02 02c38b046055858679daf9468ac44c991cce4bf91f9f8f4eab6ea7f9d2041e499f 0335676ec077b748a253dd92a1ca9387533818e511741281ebc96d61eccd86cf39 03165f2a7bbd0789c795f66ca0c383d963fa17bf4289d9faae8b8b8f098b3e669f 032c3263ced2ce21ac62ff8828f67cf12a4cc3cb93edfd432ea4a1cba2d533bae8 0304f4f8a3039ab91a1bbe211a1e16b80b549d9feffb4b83cd9e4d43d7e55964d1 03e3dfe07b7c83bdd7908795f890ba8de2117fc3303d048edd54a72de1183ed737 02ef28db8ca852fd8f871835d31a600728cf1f76744dc6b22c9d36394d146a04e5 03ce600e3f61f8b72de1715654010fa54da7a12d39b8d8cb9969f160888eaa2e0e 02df6d74c70b197cf0fc65216ab5ce25b120338a02049a45907f6e54b2e7c779b8 029f5f53b28673bb834c082f3ccd4e73c1a2099368fe7b8567cb817b7675531e26 023ccd807197e3af4139ad4647a0350bef5829f41651729defac3863e964cd3cb1 02482d77f0fb886bb23d9c431960933499982f6ecaf45f2e279203dbe642aca03b 02312b2cac8fb58150596ce11f7db6a50775d257fb8f9bdd1a3e129eef10ea182c 038c6bd3d819d30aa07cb52a2ca4aaaaf83e63bc9947a9e0230abe5233af1c12dd 038e3b9db1442165010102596f30536020e451b6e645ef1fb0c21cb965f84e3eee 14 OP_CHECKMULTISIG",
195+
"pubkeys": [
196+
"0255355ca83c973f1d97ce0e3843c85d78905af16b4dc531bc488e57212d230116",
197+
"03e3e592638b492e642f8c389f9577b0809d4f73032c4c0f9981cb57cb3eebbe4c",
198+
"02b0a4e912141c3b1044cdc13f196ff95c916f05f43d04184b7dcefa6977fac24a",
199+
"02958361ee738c994b5e799c13c964602915eaa847ed7e5a5a3f8c42312cd39a61",
200+
"0227654f4d0ddea28183970c7532692fadf8dd042e31a51c5936f85487c5a1ec02",
201+
"02c38b046055858679daf9468ac44c991cce4bf91f9f8f4eab6ea7f9d2041e499f",
202+
"0335676ec077b748a253dd92a1ca9387533818e511741281ebc96d61eccd86cf39",
203+
"03165f2a7bbd0789c795f66ca0c383d963fa17bf4289d9faae8b8b8f098b3e669f",
204+
"032c3263ced2ce21ac62ff8828f67cf12a4cc3cb93edfd432ea4a1cba2d533bae8",
205+
"0304f4f8a3039ab91a1bbe211a1e16b80b549d9feffb4b83cd9e4d43d7e55964d1",
206+
"03e3dfe07b7c83bdd7908795f890ba8de2117fc3303d048edd54a72de1183ed737",
207+
"02ef28db8ca852fd8f871835d31a600728cf1f76744dc6b22c9d36394d146a04e5",
208+
"03ce600e3f61f8b72de1715654010fa54da7a12d39b8d8cb9969f160888eaa2e0e",
209+
"02df6d74c70b197cf0fc65216ab5ce25b120338a02049a45907f6e54b2e7c779b8",
210+
"029f5f53b28673bb834c082f3ccd4e73c1a2099368fe7b8567cb817b7675531e26",
211+
"023ccd807197e3af4139ad4647a0350bef5829f41651729defac3863e964cd3cb1",
212+
"02482d77f0fb886bb23d9c431960933499982f6ecaf45f2e279203dbe642aca03b",
213+
"02312b2cac8fb58150596ce11f7db6a50775d257fb8f9bdd1a3e129eef10ea182c",
214+
"038c6bd3d819d30aa07cb52a2ca4aaaaf83e63bc9947a9e0230abe5233af1c12dd",
215+
"038e3b9db1442165010102596f30536020e451b6e645ef1fb0c21cb965f84e3eee"
216+
],
217+
"signatures": null,
218+
"input": null,
219+
"witness": null
220+
}
183221
}
184222
],
185223
"invalid": [
@@ -225,14 +263,14 @@
225263
},
226264
{
227265
"description": "m is 0",
228-
"exception": "Output is invalid",
266+
"exception": "Invalid opcode: expected OP_1–OP_16, got 0",
229267
"arguments": {
230268
"output": "OP_0 OP_2 OP_CHECKMULTISIG"
231269
}
232270
},
233271
{
234272
"description": "n is 0 (m > n)",
235-
"exception": "Output is invalid",
273+
"exception": "Invalid opcode: expected OP_1–OP_16, got 0",
236274
"arguments": {
237275
"output": "OP_2 OP_0 OP_CHECKMULTISIG"
238276
}
@@ -368,6 +406,13 @@
368406
],
369407
"input": "OP_0 ffffffffffffffff"
370408
}
409+
},
410+
{
411+
"description": "n > 20 (2-of-21 multisig)",
412+
"exception": "Output is invalid",
413+
"arguments": {
414+
"output": "OP_2 020000000000000000000000000000000000000000000000000000000000000001 020000000000000000000000000000000000000000000000000000000000000002 020000000000000000000000000000000000000000000000000000000000000003 020000000000000000000000000000000000000000000000000000000000000004 020000000000000000000000000000000000000000000000000000000000000005 020000000000000000000000000000000000000000000000000000000000000006 020000000000000000000000000000000000000000000000000000000000000007 020000000000000000000000000000000000000000000000000000000000000008 020000000000000000000000000000000000000000000000000000000000000009 02000000000000000000000000000000000000000000000000000000000000000a 02000000000000000000000000000000000000000000000000000000000000000b 02000000000000000000000000000000000000000000000000000000000000000c 02000000000000000000000000000000000000000000000000000000000000000d 02000000000000000000000000000000000000000000000000000000000000000e 02000000000000000000000000000000000000000000000000000000000000000f 020000000000000000000000000000000000000000000000000000000000000010 020000000000000000000000000000000000000000000000000000000000000011 020000000000000000000000000000000000000000000000000000000000000012 020000000000000000000000000000000000000000000000000000000000000013 020000000000000000000000000000000000000000000000000000000000000014 15 OP_CHECKMULTISIG"
415+
}
371416
}
372417
],
373418
"dynamic": {

test/integration/transactions.spec.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,68 @@ describe('bitcoinjs-lib (transactions with psbt)', () => {
451451
});
452452
});
453453

454+
it(
455+
'can create (and broadcast via 3PBP) a Transaction, w/ a ' +
456+
'P2WSH(P2MS(20 of 20)) input',
457+
async () => {
458+
const keys = [];
459+
for (let i = 0; i < 20; i++) {
460+
keys.push(ECPair.makeRandom({ network: regtest, rng }));
461+
}
462+
463+
const multisig = createPayment('p2wsh-p2ms(20 of 20)', keys);
464+
465+
const inputData = await getInputData(
466+
5e5,
467+
multisig.payment,
468+
true,
469+
'p2wsh',
470+
);
471+
{
472+
const { hash, index, witnessUtxo, witnessScript } = inputData;
473+
assert.deepStrictEqual(
474+
{ hash, index, witnessUtxo, witnessScript },
475+
inputData,
476+
);
477+
}
478+
479+
const psbt = new bitcoin.Psbt({ network: regtest })
480+
.addInput(inputData)
481+
.addOutput({
482+
address: regtestUtils.RANDOM_ADDRESS,
483+
value: BigInt(3e5),
484+
});
485+
486+
for (let i = 0; i < 20; i++) {
487+
psbt.signInput(0, multisig.keys[i]);
488+
}
489+
490+
for (let i = 0; i < 20; i++) {
491+
assert.strictEqual(
492+
psbt.validateSignaturesOfInput(
493+
0,
494+
validator,
495+
multisig.keys[i].publicKey,
496+
),
497+
true,
498+
);
499+
}
500+
501+
psbt.finalizeAllInputs();
502+
503+
const tx = psbt.extractTransaction();
504+
505+
await regtestUtils.broadcast(tx.toHex());
506+
507+
await regtestUtils.verify({
508+
txId: tx.getId(),
509+
address: regtestUtils.RANDOM_ADDRESS,
510+
vout: 0,
511+
value: 3e5,
512+
});
513+
},
514+
);
515+
454516
it(
455517
'can create (and broadcast via 3PBP) a Transaction, w/ a ' +
456518
'P2SH(P2WSH(P2MS(3 of 4))) (SegWit multisig) input',

ts_src/payments/p2ms.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { bitcoin as BITCOIN_NETWORK } from '../networks.js';
22
import * as bscript from '../script.js';
3+
import * as scriptNumber from '../script_number.js';
34
import { BufferSchema, isPoint, stacksEqual } from '../types.js';
45
import { Payment, PaymentOpts, Stack } from './index.js';
56
import * as lazy from './lazy.js';
@@ -8,6 +9,22 @@ const OPS = bscript.OPS;
89

910
const OP_INT_BASE = OPS.OP_RESERVED; // OP_1 - 1
1011

12+
function encodeSmallOrScriptNum(n: number): number | Uint8Array {
13+
return n <= 16 ? OP_INT_BASE + n : scriptNumber.encode(n);
14+
}
15+
function decodeSmallOrScriptNum(chunk: number | Uint8Array): number {
16+
if (typeof chunk === 'number') {
17+
const val = chunk - OP_INT_BASE;
18+
if (val < 1 || val > 16)
19+
throw new TypeError(`Invalid opcode: expected OP_1–OP_16, got ${chunk}`);
20+
return val;
21+
} else return scriptNumber.decode(chunk);
22+
}
23+
function isSmallOrScriptNum(chunk: number | Uint8Array): boolean {
24+
if (typeof chunk === 'number')
25+
return chunk - OP_INT_BASE >= 1 && chunk - OP_INT_BASE <= 16;
26+
else return Number.isInteger(scriptNumber.decode(chunk));
27+
}
1128
// input: OP_0 [signatures ...]
1229
// output: m [pubKeys ...] n OP_CHECKMULTISIG
1330
/**
@@ -65,8 +82,9 @@ export function p2ms(a: Payment, opts?: PaymentOpts): Payment {
6582
if (decoded) return;
6683
decoded = true;
6784
chunks = bscript.decompile(output) as Stack;
68-
o.m = (chunks[0] as number) - OP_INT_BASE;
69-
o.n = (chunks[chunks.length - 2] as number) - OP_INT_BASE;
85+
if (chunks.length < 3) throw new TypeError('Output is invalid');
86+
o.m = decodeSmallOrScriptNum(chunks[0]);
87+
o.n = decodeSmallOrScriptNum(chunks[chunks.length - 2]);
7088
o.pubkeys = chunks.slice(1, -2) as Uint8Array[];
7189
}
7290

@@ -76,9 +94,9 @@ export function p2ms(a: Payment, opts?: PaymentOpts): Payment {
7694
if (!a.pubkeys) return;
7795
return bscript.compile(
7896
([] as Stack).concat(
79-
OP_INT_BASE + a.m,
97+
encodeSmallOrScriptNum(a.m),
8098
a.pubkeys,
81-
OP_INT_BASE + o.n,
99+
encodeSmallOrScriptNum(o.n),
82100
OPS.OP_CHECKMULTISIG,
83101
),
84102
);
@@ -118,14 +136,14 @@ export function p2ms(a: Payment, opts?: PaymentOpts): Payment {
118136
if (opts.validate) {
119137
if (a.output) {
120138
decode(a.output);
121-
v.parse(v.number(), chunks[0], { message: 'Output is invalid' });
122-
v.parse(v.number(), chunks[chunks.length - 2], {
123-
message: 'Output is invalid',
124-
});
139+
if (!isSmallOrScriptNum(chunks[0]))
140+
throw new TypeError('Output is invalid');
141+
if (!isSmallOrScriptNum(chunks[chunks.length - 2]))
142+
throw new TypeError('Output is invalid');
125143
if (chunks[chunks.length - 1] !== OPS.OP_CHECKMULTISIG)
126144
throw new TypeError('Output is invalid');
127145

128-
if (o.m! <= 0 || o.n! > 16 || o.m! > o.n! || o.n !== chunks.length - 3)
146+
if (o.m! <= 0 || o.n! > 20 || o.m! > o.n! || o.n !== chunks.length - 3)
129147
throw new TypeError('Output is invalid');
130148
if (!o.pubkeys!.every(x => isPoint(x)))
131149
throw new TypeError('Output is invalid');

0 commit comments

Comments
 (0)