@bsv/templates 1.0.0 → 1.1.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.
Files changed (36) hide show
  1. package/dist/cjs/mod.js +22 -7
  2. package/dist/cjs/mod.js.map +1 -1
  3. package/dist/cjs/package.json +2 -2
  4. package/dist/cjs/src/Metanet.js +14 -14
  5. package/dist/cjs/src/Metanet.js.map +1 -1
  6. package/dist/cjs/src/MultiPushDrop.js +278 -0
  7. package/dist/cjs/src/MultiPushDrop.js.map +1 -0
  8. package/dist/cjs/src/OpReturn.js +13 -13
  9. package/dist/cjs/src/OpReturn.js.map +1 -1
  10. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  11. package/dist/esm/mod.js +1 -0
  12. package/dist/esm/mod.js.map +1 -1
  13. package/dist/esm/src/Metanet.js +14 -14
  14. package/dist/esm/src/Metanet.js.map +1 -1
  15. package/dist/esm/src/MultiPushDrop.js +275 -0
  16. package/dist/esm/src/MultiPushDrop.js.map +1 -0
  17. package/dist/esm/src/OpReturn.js +13 -13
  18. package/dist/esm/src/OpReturn.js.map +1 -1
  19. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  20. package/dist/types/mod.d.ts +1 -0
  21. package/dist/types/mod.d.ts.map +1 -1
  22. package/dist/types/src/Metanet.d.ts +13 -13
  23. package/dist/types/src/Metanet.d.ts.map +1 -1
  24. package/dist/types/src/MultiPushDrop.d.ts +66 -0
  25. package/dist/types/src/MultiPushDrop.d.ts.map +1 -0
  26. package/dist/types/src/OpReturn.d.ts +12 -12
  27. package/dist/types/src/OpReturn.d.ts.map +1 -1
  28. package/dist/types/tsconfig.types.tsbuildinfo +1 -1
  29. package/mod.ts +1 -0
  30. package/package.json +4 -4
  31. package/src/Metanet.ts +37 -37
  32. package/src/MultiPushDrop.ts +317 -0
  33. package/src/OpReturn.ts +32 -32
  34. package/src/__tests/Metanet.test.ts +15 -15
  35. package/src/__tests/MultiPushDrop.test.ts +256 -0
  36. package/src/__tests/OpReturn.test.ts +7 -7
@@ -0,0 +1,317 @@
1
+ import {
2
+ ScriptTemplate,
3
+ LockingScript,
4
+ UnlockingScript,
5
+ OP,
6
+ ScriptTemplateUnlock,
7
+ Utils,
8
+ Hash,
9
+ TransactionSignature,
10
+ Signature,
11
+ WalletInterface, SecurityLevel, WalletCounterparty,
12
+ Transaction,
13
+ PubKeyHex
14
+ } from '@bsv/sdk'
15
+
16
+ // Helper to ensure a value is not null or undefined
17
+ function verifyTruthy<T> (v: T | undefined | null, err?: string): T {
18
+ if (v === null || v === undefined) throw new Error(err || 'Value must not be null or undefined')
19
+ return v
20
+ }
21
+
22
+ // Helper to create minimally encoded script chunks (same as in PushDrop)
23
+ const createMinimallyEncodedScriptChunk = (
24
+ data: number[]
25
+ ): { op: number, data?: number[] } => {
26
+ if (data.length === 0) return { op: 0 } // OP_0
27
+ if (data.length === 1 && data[0] === 0) return { op: 0 } // OP_0
28
+ if (data.length === 1 && data[0] > 0 && data[0] <= 16) return { op: 0x50 + data[0] } // OP_1 to OP_16
29
+ if (data.length === 1 && data[0] === 0x81) return { op: 0x4f } // OP_1NEGATE
30
+ if (data.length <= 75) return { op: data.length, data }
31
+ if (data.length <= 255) return { op: 0x4c, data } // OP_PUSHDATA1
32
+ if (data.length <= 65535) return { op: 0x4d, data } // OP_PUSHDATA2
33
+ return { op: 0x4e, data } // OP_PUSHDATA4
34
+ }
35
+
36
+ /**
37
+ * Represents the decoded structure of a MultiPushDrop locking script.
38
+ */
39
+ export interface MultiPushDropDecoded {
40
+ lockingPublicKeys: PubKeyHex[]
41
+ fields: number[][]
42
+ }
43
+
44
+ /**
45
+ * MultiPushDrop Script Template
46
+ *
47
+ * This template creates locking scripts that allow spending by any one of multiple
48
+ * specified public keys (1-of-N). It also pushes arbitrary data fields onto the stack,
49
+ * which are dropped after the signature check.
50
+ *
51
+ * When using this among adversarial or non-trusted groups, the MASSIVE caveat is that
52
+ * there is no constraint enforcing that any group members are kept in the loop. Any group
53
+ * member can trivially destroy the token. For more practical non-trusted arrangements,
54
+ * techniques like OP_PUSH_TX should be used instead.
55
+ *
56
+ * There's also a known bug in this implementation where it won't work with over around 120
57
+ * keys but involving more than a few people than just a few into a FULLY TRUST BASED exchange
58
+ * is never a good idea. Use a more robust, application-specific mechanism.
59
+ */
60
+ export default class MultiPushDrop implements ScriptTemplate {
61
+ wallet: WalletInterface
62
+ originator?: string
63
+
64
+ /**
65
+ * Decodes a MultiPushDrop locking script back into its data fields and the list of locking public keys.
66
+ * @param script The MultiPushDrop locking script to decode.
67
+ * @returns {MultiPushDropDecoded} An object containing the locking public keys and data fields.
68
+ * @throws {Error} If the script structure is not a valid MultiPushDrop script.
69
+ */
70
+ static decode (script: LockingScript): MultiPushDropDecoded {
71
+ const chunks = script.chunks
72
+ let cursor = 0
73
+
74
+ // Decode keys until they stop being 33 bytes long
75
+ const lockingPublicKeys: PubKeyHex[] = []
76
+ while (chunks[cursor].data?.length === 33) {
77
+ const keyChunk = verifyTruthy(chunks[cursor], `Missing public key chunk ${cursor}`)
78
+ const keyData = verifyTruthy(keyChunk.data, `Public key chunk ${cursor} has no data`)
79
+ lockingPublicKeys.push(Utils.toHex(keyData))
80
+ cursor++
81
+ }
82
+
83
+ // Skip the nPublicKeys chunk and opcodes.
84
+ // This amounts to 8 items to skip.
85
+ cursor += 8
86
+
87
+ // Decode Data Fields
88
+ const fields: number[][] = []
89
+ for (let i = cursor; i < chunks.length; i++) {
90
+ const nextOpcode = chunks[i + 1]?.op
91
+ const chunkData = chunks[i].data ?? [] // Use OP code for OP_0-OP_16 etc. if data is null
92
+
93
+ let currentField: number[] = []
94
+ if (chunkData.length > 0) {
95
+ currentField = chunkData
96
+ } else if (chunks[i].op >= OP.OP_1 && chunks[i].op <= OP.OP_16) {
97
+ currentField = [chunks[i].op - OP.OP_1 + 1]
98
+ } else if (chunks[i].op === OP.OP_0) {
99
+ currentField = [] // Represent OP_0 as empty array
100
+ } else if (chunks[i].op === OP.OP_1NEGATE) {
101
+ currentField = [0x81]
102
+ } else if (chunks[i].op === OP.OP_DROP || chunks[i].op === OP.OP_2DROP) {
103
+ // Stop before the drops
104
+ break
105
+ } else {
106
+ // Assume it's a data push even if data is empty for some reason
107
+ currentField = chunkData
108
+ }
109
+ fields.push(currentField)
110
+ // If the next opcode is a DROP, we've found the last field
111
+ if (nextOpcode === OP.OP_DROP || nextOpcode === OP.OP_2DROP) {
112
+ break
113
+ }
114
+ }
115
+
116
+ return {
117
+ lockingPublicKeys,
118
+ fields
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Constructs a new instance of the MultiPushDrop class.
124
+ *
125
+ * @param {WalletInterface} wallet - The wallet interface used for deriving keys and signing.
126
+ * @param {string} [originator] - The originator domain for wallet requests.
127
+ */
128
+ constructor (wallet: WalletInterface, originator?: string) {
129
+ this.wallet = wallet
130
+ this.originator = originator
131
+ }
132
+
133
+ /**
134
+ * Creates a MultiPushDrop locking script.
135
+ *
136
+ * @param {number[][]} fields - The arbitrary data fields to include in the script.
137
+ * @param {[SecurityLevel, string]} protocolID - The protocol ID used for key derivation.
138
+ * @param {string} keyID - The key ID used for key derivation.
139
+ * @param {WalletCounterparty[]} counterparties - An array of counterparties ('self' or PubKeyHex) whose derived keys can unlock the script. Must contain at least one.
140
+ * @returns {Promise<LockingScript>} The generated MultiPushDrop locking script.
141
+ * @throws {Error} If counterparties array is empty.
142
+ */
143
+ async lock (
144
+ fields: number[][],
145
+ protocolID: [SecurityLevel, string],
146
+ keyID: string,
147
+ counterparties: WalletCounterparty[]
148
+ ): Promise<LockingScript> {
149
+ if (!Array.isArray(counterparties) || counterparties.length === 0) {
150
+ throw new Error('MultiPushDrop requires at least one counterparty.')
151
+ }
152
+
153
+ const publicKeys: string[] = []
154
+ for (const counterparty of counterparties) {
155
+ const { publicKey } = await this.wallet.getPublicKey({
156
+ protocolID,
157
+ keyID,
158
+ counterparty
159
+ }, this.originator)
160
+ publicKeys.push(publicKey)
161
+ }
162
+
163
+ const nPublicKeys = publicKeys.length
164
+ const lockPart: Array<{ op: number, data?: number[] }> = []
165
+
166
+ // Push Public Keys
167
+ for (const publicKeyHex of publicKeys) {
168
+ lockPart.push({
169
+ op: publicKeyHex.length / 2, // Length of compressed pubkey is 33 bytes (66 hex)
170
+ data: Utils.toArray(publicKeyHex, 'hex')
171
+ })
172
+ }
173
+
174
+ // Pick the value on the stack that's right before the locking script.
175
+ // This should be the index of the key to use in the unlock.
176
+ lockPart.push(createMinimallyEncodedScriptChunk([nPublicKeys]))
177
+ lockPart.push({ op: OP.OP_PICK })
178
+
179
+ // Now we use the index to get the actual key.
180
+ lockPart.push({ op: OP.OP_PICK })
181
+
182
+ // We pull the signature from the bottom of the stack, no matter the number of keys.
183
+ lockPart.push({ op: OP.OP_DEPTH })
184
+ lockPart.push({ op: OP.OP_1SUB })
185
+ lockPart.push({ op: OP.OP_PICK })
186
+
187
+ // We swap the signature and public key so they're in the correct order, then CHECKSIGVERIFY
188
+ lockPart.push({ op: OP.OP_SWAP })
189
+ lockPart.push({ op: OP.OP_CHECKSIGVERIFY })
190
+
191
+ // Construct PushDrop Part for fields
192
+ const pushDropPart: Array<{ op: number, data?: number[] }> = []
193
+ for (const field of fields) {
194
+ pushDropPart.push(createMinimallyEncodedScriptChunk(field))
195
+ }
196
+
197
+ // Add Drop Opcodes
198
+ // We need to drop N keys, the number N itself, and M fields after verification succeeds.
199
+ // We also copied the signature itself so we need to drop that.
200
+ // Then we push a single true.
201
+ let itemsToDrop = fields.length + nPublicKeys + 2
202
+ while (itemsToDrop > 1) {
203
+ pushDropPart.push({ op: OP.OP_2DROP })
204
+ itemsToDrop -= 2
205
+ }
206
+ if (itemsToDrop === 1) {
207
+ pushDropPart.push({ op: OP.OP_DROP })
208
+ }
209
+
210
+ // Combine parts and return
211
+ return new LockingScript([
212
+ ...lockPart,
213
+ ...pushDropPart,
214
+ { op: OP.OP_TRUE }
215
+ ])
216
+ }
217
+
218
+ /**
219
+ * Creates an unlocking script template for spending a MultiPushDrop output.
220
+ *
221
+ * @param {[SecurityLevel, string]} protocolID - The protocol ID used for key derivation.
222
+ * @param {string} keyID - The key ID used for key derivation.
223
+ * @param {WalletCounterparty} creator - The identity key of the person who made the locking script. Could come from one of the fields or be passed off chain.
224
+ * @param {'all' | 'none' | 'single'} [signOutputs='all'] - Specifies which transaction outputs to sign.
225
+ * @param {boolean} [anyoneCanPay=false] - Specifies if the SIGHASH_ANYONECANPAY flag should be used.
226
+ * @returns {ScriptTemplateUnlock} An object containing `sign` and `estimateLength` functions.
227
+ * @throws {Error} If we are not found in the list of keys, or if required signing info (sourceTXID, satoshis, lockingScript) is missing.
228
+ */
229
+ unlock (
230
+ protocolID: [SecurityLevel, string],
231
+ keyID: string,
232
+ creator: WalletCounterparty,
233
+ signOutputs: 'all' | 'none' | 'single' = 'all',
234
+ anyoneCanPay = false
235
+ ): ScriptTemplateUnlock {
236
+ return {
237
+ sign: async (
238
+ tx: Transaction,
239
+ inputIndex: number
240
+ ): Promise<UnlockingScript> => {
241
+ // Prepare for signing
242
+ let signatureScope = TransactionSignature.SIGHASH_FORKID
243
+ if (signOutputs === 'all') signatureScope |= TransactionSignature.SIGHASH_ALL
244
+ else if (signOutputs === 'none') signatureScope |= TransactionSignature.SIGHASH_NONE
245
+ else if (signOutputs === 'single') signatureScope |= TransactionSignature.SIGHASH_SINGLE
246
+ if (anyoneCanPay) signatureScope |= TransactionSignature.SIGHASH_ANYONECANPAY
247
+ const input = tx.inputs[inputIndex]
248
+ const currentSourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
249
+ const currentSourceSatoshis = input.sourceTransaction?.outputs[input.sourceOutputIndex].satoshis
250
+ const currentLockingScript = input.sourceTransaction?.outputs[input.sourceOutputIndex]?.lockingScript
251
+ if (typeof currentSourceTXID !== 'string') throw new Error('Input sourceTXID or sourceTransaction required for signing.')
252
+ if (currentSourceSatoshis === undefined) throw new Error('Input sourceSatoshis or sourceTransaction required for signing.')
253
+ if (currentLockingScript == null) throw new Error('Input lockingScript or sourceTransaction required for signing.')
254
+ const otherInputs = tx.inputs.filter((_, index) => index !== inputIndex)
255
+ const decoded = MultiPushDrop.decode(currentLockingScript)
256
+
257
+ // Find the index of the unlocker's public key
258
+ let unlockerIndex = -1
259
+ const { publicKey: unlockerPubKeyHex } = await this.wallet.getPublicKey({
260
+ protocolID,
261
+ keyID,
262
+ counterparty: creator,
263
+ forSelf: true
264
+ }, this.originator)
265
+ for (let i = 0; i < decoded.lockingPublicKeys.length; i++) {
266
+ if (decoded.lockingPublicKeys[i] === unlockerPubKeyHex) {
267
+ unlockerIndex = i
268
+ break
269
+ }
270
+ }
271
+ if (unlockerIndex === -1) {
272
+ throw new Error(`Unlocker key derived for counterparty (creator) "${creator}" not found in the list of locking keys.`)
273
+ }
274
+ unlockerIndex = decoded.lockingPublicKeys.length - 1 - unlockerIndex
275
+
276
+ // Calculate Preimage
277
+ const preimage = TransactionSignature.format({
278
+ sourceTXID: currentSourceTXID,
279
+ sourceOutputIndex: verifyTruthy(input.sourceOutputIndex),
280
+ sourceSatoshis: currentSourceSatoshis,
281
+ transactionVersion: tx.version,
282
+ otherInputs,
283
+ inputIndex,
284
+ outputs: tx.outputs,
285
+ inputSequence: input.sequence ?? 0xffffffff,
286
+ subscript: currentLockingScript,
287
+ lockTime: tx.lockTime,
288
+ scope: signatureScope
289
+ })
290
+
291
+ // Create Signature
292
+ const preimageHash = Hash.hash256(preimage)
293
+ const { signature: bareSignature } = await this.wallet.createSignature({
294
+ hashToDirectlySign: preimageHash,
295
+ protocolID,
296
+ keyID,
297
+ counterparty: creator
298
+ }, this.originator)
299
+ const signature = Signature.fromDER([...bareSignature])
300
+ const txSignature = new TransactionSignature(signature.r, signature.s, signatureScope)
301
+ const sigForScript = txSignature.toChecksigFormat()
302
+
303
+ // Create Unlocking Script Chunks: <Signature> <Index>
304
+ const unlockingChunks: Array<{ op: number, data?: number[] }> = []
305
+ unlockingChunks.push({ op: sigForScript.length, data: sigForScript })
306
+ unlockingChunks.push(createMinimallyEncodedScriptChunk([unlockerIndex]))
307
+ return new UnlockingScript(unlockingChunks)
308
+ },
309
+ // Estimate length: Signature (~71-73 bytes) + Index push (1 byte for 0-15, potentially more)
310
+ estimateLength: async (): Promise<number> => {
311
+ // A conservative estimate, usually 73 + 1 = 74
312
+ // Could potentially be larger if index > 15, but that's rare.
313
+ return 74
314
+ }
315
+ }
316
+ }
317
+ }
package/src/OpReturn.ts CHANGED
@@ -6,52 +6,52 @@ import { OP, Script, ScriptTemplate, LockingScript, UnlockingScript, Transaction
6
6
  * This class provides methods to create OpReturn scripts from data. Only lock script is available.
7
7
  */
8
8
  export default class OpReturn implements ScriptTemplate {
9
- /**
9
+ /**
10
10
  * Creates an OpReturn script
11
11
  *
12
12
  * @param {string | string[] | number[]} data The data or array of data to push after OP_RETURN.
13
13
  * @param {('hex' | 'utf8' | 'base64')} enc The data encoding type, defaults to utf8.
14
14
  * @returns {LockingScript} - An OpReturn locking script.
15
15
  */
16
- lock(data: string | string[] | number[], enc?: 'hex' | 'utf8' | 'base64'): LockingScript {
17
- const script: { op: number, data?: number[] }[] = [
18
- { op: OP.OP_FALSE },
19
- { op: OP.OP_RETURN }
20
- ]
16
+ lock (data: string | string[] | number[], enc?: 'hex' | 'utf8' | 'base64'): LockingScript {
17
+ const script: Array<{ op: number, data?: number[] }> = [
18
+ { op: OP.OP_FALSE },
19
+ { op: OP.OP_RETURN }
20
+ ]
21
21
 
22
- if (typeof data === 'string') {
23
- data = [data]
24
- }
25
-
26
- if (data.length && typeof data[0] === 'number') {
27
- script.push({ op: data.length, data: data as number[] })
28
- } else {
29
- for (const entry of data.filter(Boolean)) {
30
- const arr = Utils.toArray(entry, enc)
31
- script.push({ op: arr.length, data: arr })
32
- }
33
- }
22
+ if (typeof data === 'string') {
23
+ data = [data]
24
+ }
34
25
 
35
- return new LockingScript(script)
26
+ if ((data.length > 0) && typeof data[0] === 'number') {
27
+ script.push({ op: data.length, data: data as number[] })
28
+ } else {
29
+ for (const entry of data.filter(Boolean)) {
30
+ const arr = Utils.toArray(entry, enc)
31
+ script.push({ op: arr.length, data: arr })
32
+ }
36
33
  }
37
34
 
38
- /**
35
+ return new LockingScript(script)
36
+ }
37
+
38
+ /**
39
39
  * Unlock method is not available for OpReturn scripts, throws exception.
40
40
  */
41
- unlock(): {
42
- sign: (tx: Transaction, inputIndex: number) => Promise<UnlockingScript>
43
- estimateLength: () => Promise<number>
44
- } {
45
- throw new Error('Unlock is not supported for OpReturn scripts')
46
- }
41
+ unlock (): {
42
+ sign: (tx: Transaction, inputIndex: number) => Promise<UnlockingScript>
43
+ estimateLength: () => Promise<number>
44
+ } {
45
+ throw new Error('Unlock is not supported for OpReturn scripts')
46
+ }
47
47
 
48
- /**
48
+ /**
49
49
  * Decodes an OpReturn script data to utf8
50
50
  * @param script The opreturn script
51
51
  * @returns An array of UTF8 encoded strings
52
52
  */
53
- static decode(script: Script): string[] {
54
- const tokens = script.toASM().split(' ').slice(2)
55
- return tokens.map(token => Utils.toUTF8(Utils.toArray(token, 'hex')))
56
- }
57
- }
53
+ static decode (script: Script): string[] {
54
+ const tokens = script.toASM().split(' ').slice(2)
55
+ return tokens.map(token => Utils.toUTF8(Utils.toArray(token, 'hex')))
56
+ }
57
+ }
@@ -2,20 +2,20 @@ import Metanet from '../Metanet'
2
2
  import { PrivateKey, Utils } from '@bsv/sdk'
3
3
 
4
4
  describe('Metanet template', () => {
5
- it('creates metanet output', () => {
6
- const priv = PrivateKey.fromRandom()
7
- const script = new Metanet().lock(priv.toPublicKey(), null, ['subprotocol', 'data'])
8
- const tokens = script.toASM().split(' ')
9
- expect(tokens[0]).toEqual('OP_0')
10
- expect(tokens[1]).toEqual('OP_RETURN')
11
- expect(Utils.toUTF8(Utils.toArray(tokens[2], 'hex'))).toEqual('meta')
12
- expect(Utils.toUTF8(Utils.toArray(tokens[3], 'hex'))).toEqual(priv.toPublicKey().toString())
13
- expect(Utils.toUTF8(Utils.toArray(tokens[4], 'hex'))).toEqual('null')
14
- expect(Utils.toUTF8(Utils.toArray(tokens[5], 'hex'))).toEqual('subprotocol')
15
- expect(Utils.toUTF8(Utils.toArray(tokens[6], 'hex'))).toEqual('data')
16
- })
5
+ it('creates metanet output', () => {
6
+ const priv = PrivateKey.fromRandom()
7
+ const script = new Metanet().lock(priv.toPublicKey(), null, ['subprotocol', 'data'])
8
+ const tokens = script.toASM().split(' ')
9
+ expect(tokens[0]).toEqual('OP_0')
10
+ expect(tokens[1]).toEqual('OP_RETURN')
11
+ expect(Utils.toUTF8(Utils.toArray(tokens[2], 'hex'))).toEqual('meta')
12
+ expect(Utils.toUTF8(Utils.toArray(tokens[3], 'hex'))).toEqual(priv.toPublicKey().toString())
13
+ expect(Utils.toUTF8(Utils.toArray(tokens[4], 'hex'))).toEqual('null')
14
+ expect(Utils.toUTF8(Utils.toArray(tokens[5], 'hex'))).toEqual('subprotocol')
15
+ expect(Utils.toUTF8(Utils.toArray(tokens[6], 'hex'))).toEqual('data')
16
+ })
17
17
 
18
- it('fails to create metanet input', () => {
19
- expect(() => new Metanet().unlock()).toThrow()
20
- })
18
+ it('fails to create metanet input', () => {
19
+ expect(() => new Metanet().unlock()).toThrow()
20
+ })
21
21
  })