@bsv/templates 1.4.1 → 1.6.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/cjs/mod.js +5 -1
- package/dist/cjs/mod.js.map +1 -1
- package/dist/cjs/package.json +14 -7
- package/dist/cjs/src/MandalaAdmin.js +84 -0
- package/dist/cjs/src/MandalaAdmin.js.map +1 -0
- package/dist/cjs/src/MandalaToken.js +84 -0
- package/dist/cjs/src/MandalaToken.js.map +1 -0
- package/dist/cjs/src/MultiPushDrop.js +4 -21
- package/dist/cjs/src/MultiPushDrop.js.map +1 -1
- package/dist/cjs/src/mandala-encoding.js +82 -0
- package/dist/cjs/src/mandala-encoding.js.map +1 -0
- package/dist/cjs/src/mandala-signing.js +43 -0
- package/dist/cjs/src/mandala-signing.js.map +1 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/esm/mod.js +2 -0
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/src/MandalaAdmin.js +81 -0
- package/dist/esm/src/MandalaAdmin.js.map +1 -0
- package/dist/esm/src/MandalaToken.js +81 -0
- package/dist/esm/src/MandalaToken.js.map +1 -0
- package/dist/esm/src/MultiPushDrop.js +1 -18
- package/dist/esm/src/MultiPushDrop.js.map +1 -1
- package/dist/esm/src/mandala-encoding.js +72 -0
- package/dist/esm/src/mandala-encoding.js.map +1 -0
- package/dist/esm/src/mandala-signing.js +39 -0
- package/dist/esm/src/mandala-signing.js.map +1 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/types/mod.d.ts +4 -0
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/src/MandalaAdmin.d.ts +27 -0
- package/dist/types/src/MandalaAdmin.d.ts.map +1 -0
- package/dist/types/src/MandalaToken.d.ts +16 -0
- package/dist/types/src/MandalaToken.d.ts.map +1 -0
- package/dist/types/src/MultiPushDrop.d.ts.map +1 -1
- package/dist/types/src/mandala-encoding.d.ts +10 -0
- package/dist/types/src/mandala-encoding.d.ts.map +1 -0
- package/dist/types/src/mandala-signing.d.ts +10 -0
- package/dist/types/src/mandala-signing.d.ts.map +1 -0
- package/dist/types/tsconfig.types.tsbuildinfo +1 -1
- package/mod.ts +4 -0
- package/package.json +14 -7
- package/src/MandalaAdmin.ts +107 -0
- package/src/MandalaToken.ts +102 -0
- package/src/MultiPushDrop.ts +1 -14
- package/src/__tests/MandalaAdmin.derive.test.ts +34 -0
- package/src/__tests/MandalaAdmin.script.test.ts +26 -0
- package/src/__tests/MandalaToken.test.ts +43 -0
- package/src/__tests/MandalaToken.unlock.test.ts +31 -0
- package/src/__tests/exports.test.ts +9 -0
- package/src/__tests/mandala-encoding.test.ts +32 -0
- package/src/mandala-encoding.ts +64 -0
- package/src/mandala-signing.ts +41 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
WalletInterface, WalletProtocol, Hash, Utils,
|
|
3
|
+
LockingScript, UnlockingScript, OP, ScriptTemplateUnlock, Transaction,
|
|
4
|
+
TransactionSignature, Signature
|
|
5
|
+
} from '@bsv/sdk'
|
|
6
|
+
import { createMinimallyEncodedScriptChunk, MARKER } from './mandala-encoding.js'
|
|
7
|
+
import { buildSighashPreimage } from './mandala-signing.js'
|
|
8
|
+
|
|
9
|
+
export type MandalaActionKind = 'register' | 'issue' | 'redeem' | 'recover'
|
|
10
|
+
|
|
11
|
+
export interface MandalaActionDetails {
|
|
12
|
+
kind: MandalaActionKind
|
|
13
|
+
assetId?: string
|
|
14
|
+
amount?: number
|
|
15
|
+
priorOutpoint?: string
|
|
16
|
+
[k: string]: unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MandalaAdminDecoded {
|
|
20
|
+
boundKey: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const canon = (value: unknown): string => {
|
|
24
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
25
|
+
if (Array.isArray(value)) return '[' + value.map(canon).join(',') + ']'
|
|
26
|
+
const keys = Object.keys(value).sort((a, b) => {
|
|
27
|
+
if (a < b) return -1
|
|
28
|
+
if (a > b) return 1
|
|
29
|
+
return 0
|
|
30
|
+
})
|
|
31
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canon((value as Record<string, unknown>)[k])).join(',') + '}'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class MandalaAdmin {
|
|
35
|
+
wallet: WalletInterface
|
|
36
|
+
originator?: string
|
|
37
|
+
|
|
38
|
+
constructor (wallet: WalletInterface, originator?: string) {
|
|
39
|
+
this.wallet = wallet
|
|
40
|
+
this.originator = originator
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static canonicalize (actionDetails: MandalaActionDetails): string {
|
|
44
|
+
return canon(actionDetails)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
static commitment (actionDetails: MandalaActionDetails): string {
|
|
48
|
+
return Utils.toHex(Hash.sha256(Utils.toArray(MandalaAdmin.canonicalize(actionDetails), 'utf8')))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async deriveBoundKey (
|
|
52
|
+
protocolID: WalletProtocol,
|
|
53
|
+
actionDetails: MandalaActionDetails
|
|
54
|
+
): Promise<{ boundKey: string, keyID: string }> {
|
|
55
|
+
const keyID = MandalaAdmin.commitment(actionDetails)
|
|
56
|
+
const { publicKey } = await this.wallet.getPublicKey({ protocolID, keyID, counterparty: 'anyone' }, this.originator)
|
|
57
|
+
return { boundKey: publicKey, keyID }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
lock (boundKey: string): LockingScript {
|
|
61
|
+
const keyBytes = Utils.toArray(boundKey, 'hex')
|
|
62
|
+
if (keyBytes.length !== 33) throw new Error('boundKey must be a 33-byte compressed public key')
|
|
63
|
+
return new LockingScript([
|
|
64
|
+
createMinimallyEncodedScriptChunk([MARKER]),
|
|
65
|
+
{ op: OP.OP_DROP },
|
|
66
|
+
{ op: keyBytes.length, data: keyBytes },
|
|
67
|
+
{ op: OP.OP_CHECKSIG }
|
|
68
|
+
])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
static decode (script: LockingScript): MandalaAdminDecoded {
|
|
72
|
+
const c = script.chunks
|
|
73
|
+
if (c.length !== 4) throw new Error('not a MandalaAdmin script: wrong chunk count')
|
|
74
|
+
const marker = c[0].data ?? []
|
|
75
|
+
if (c[0].op !== 1 || marker.length !== 1 || marker[0] !== MARKER) throw new Error('not a MandalaAdmin script: missing marker')
|
|
76
|
+
if (c[1].op !== OP.OP_DROP || c[3].op !== OP.OP_CHECKSIG) throw new Error('not a MandalaAdmin script: bad shape')
|
|
77
|
+
const keyData = c[2].data
|
|
78
|
+
if (keyData?.length !== 33) throw new Error('not a MandalaAdmin script: bad boundKey')
|
|
79
|
+
return { boundKey: Utils.toHex(keyData) }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
unlock (
|
|
83
|
+
protocolID: WalletProtocol,
|
|
84
|
+
actionDetails: MandalaActionDetails,
|
|
85
|
+
signOutputs: 'all' | 'none' | 'single' = 'all',
|
|
86
|
+
anyoneCanPay = false
|
|
87
|
+
): ScriptTemplateUnlock {
|
|
88
|
+
return {
|
|
89
|
+
sign: async (tx: Transaction, inputIndex: number): Promise<UnlockingScript> => {
|
|
90
|
+
const { preimage, scope } = buildSighashPreimage(tx, inputIndex, signOutputs, anyoneCanPay)
|
|
91
|
+
|
|
92
|
+
const keyID = MandalaAdmin.commitment(actionDetails)
|
|
93
|
+
const { signature: bareSignature } = await this.wallet.createSignature({
|
|
94
|
+
hashToDirectlySign: Hash.hash256(preimage),
|
|
95
|
+
protocolID,
|
|
96
|
+
keyID,
|
|
97
|
+
counterparty: 'anyone'
|
|
98
|
+
}, this.originator)
|
|
99
|
+
const signature = Signature.fromDER([...bareSignature])
|
|
100
|
+
const txSignature = new TransactionSignature(signature.r, signature.s, scope)
|
|
101
|
+
const sigForScript = txSignature.toChecksigFormat()
|
|
102
|
+
return new UnlockingScript([{ op: sigForScript.length, data: sigForScript }])
|
|
103
|
+
},
|
|
104
|
+
estimateLength: async (_tx?: Transaction, _inputIndex?: number) => 74
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ScriptTemplate, ScriptTemplateUnlock, LockingScript, UnlockingScript, OP, Utils,
|
|
3
|
+
WalletInterface, WalletProtocol, WalletCounterparty, Transaction, Hash,
|
|
4
|
+
TransactionSignature, PrivateKey
|
|
5
|
+
} from '@bsv/sdk'
|
|
6
|
+
import {
|
|
7
|
+
createMinimallyEncodedScriptChunk, encodeScriptNum, decodeScriptNum,
|
|
8
|
+
encodeAssetId, decodeAssetId, MARKER
|
|
9
|
+
} from './mandala-encoding.js'
|
|
10
|
+
import { buildSighashPreimage } from './mandala-signing.js'
|
|
11
|
+
|
|
12
|
+
// Local helper since Utils.verifyTruthy is not available in @bsv/sdk
|
|
13
|
+
const vt = <T>(v: T | undefined | null): T => {
|
|
14
|
+
if (v == null) throw new Error('missing chunk data')
|
|
15
|
+
return v
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MandalaTokenDecoded {
|
|
19
|
+
assetId: string
|
|
20
|
+
amount: number
|
|
21
|
+
pubKeyHash: number[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class MandalaToken implements ScriptTemplate {
|
|
25
|
+
wallet?: WalletInterface
|
|
26
|
+
originator?: string
|
|
27
|
+
|
|
28
|
+
constructor (wallet?: WalletInterface, originator?: string) {
|
|
29
|
+
this.wallet = wallet
|
|
30
|
+
this.originator = originator
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async lockBRC29 (
|
|
34
|
+
assetId: string,
|
|
35
|
+
amount: number,
|
|
36
|
+
protocolID: WalletProtocol,
|
|
37
|
+
keyID: string,
|
|
38
|
+
counterparty: WalletCounterparty
|
|
39
|
+
): Promise<LockingScript> {
|
|
40
|
+
if (this.wallet == null) throw new Error('lockBRC29 requires a wallet')
|
|
41
|
+
const { publicKey } = await this.wallet.getPublicKey({ protocolID, keyID, counterparty }, this.originator)
|
|
42
|
+
const pubKeyHash = Hash.hash160(Utils.toArray(publicKey, 'hex'))
|
|
43
|
+
return this.lock(assetId, amount, pubKeyHash)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
lock (assetId: string, amount: number, pubKeyHash: number[]): LockingScript {
|
|
47
|
+
if (pubKeyHash.length !== 20) throw new Error('pubKeyHash must be 20 bytes')
|
|
48
|
+
if (!Number.isInteger(amount) || amount < 1) throw new Error('amount must be a positive integer')
|
|
49
|
+
const assetIdBytes = encodeAssetId(assetId)
|
|
50
|
+
return new LockingScript([
|
|
51
|
+
createMinimallyEncodedScriptChunk([MARKER]),
|
|
52
|
+
createMinimallyEncodedScriptChunk(assetIdBytes),
|
|
53
|
+
createMinimallyEncodedScriptChunk(encodeScriptNum(amount)),
|
|
54
|
+
{ op: OP.OP_2DROP },
|
|
55
|
+
{ op: OP.OP_DROP },
|
|
56
|
+
{ op: OP.OP_DUP },
|
|
57
|
+
{ op: OP.OP_HASH160 },
|
|
58
|
+
{ op: pubKeyHash.length, data: pubKeyHash },
|
|
59
|
+
{ op: OP.OP_EQUALVERIFY },
|
|
60
|
+
{ op: OP.OP_CHECKSIG }
|
|
61
|
+
])
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
unlock (
|
|
65
|
+
privateKey: PrivateKey,
|
|
66
|
+
signOutputs: 'all' | 'none' | 'single' = 'all',
|
|
67
|
+
anyoneCanPay = false
|
|
68
|
+
): ScriptTemplateUnlock {
|
|
69
|
+
return {
|
|
70
|
+
sign: async (tx: Transaction, inputIndex: number): Promise<UnlockingScript> => {
|
|
71
|
+
const { preimage, scope } = buildSighashPreimage(tx, inputIndex, signOutputs, anyoneCanPay)
|
|
72
|
+
|
|
73
|
+
const rawSignature = privateKey.sign(Hash.sha256(preimage))
|
|
74
|
+
const sig = new TransactionSignature(rawSignature.r, rawSignature.s, scope)
|
|
75
|
+
const sigForScript = sig.toChecksigFormat()
|
|
76
|
+
const pubkeyForScript = privateKey.toPublicKey().encode(true) as number[]
|
|
77
|
+
return new UnlockingScript([
|
|
78
|
+
{ op: sigForScript.length, data: sigForScript },
|
|
79
|
+
{ op: pubkeyForScript.length, data: pubkeyForScript }
|
|
80
|
+
])
|
|
81
|
+
},
|
|
82
|
+
estimateLength: async (tx?: Transaction, inputIndex?: number) => 108
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
static decode (script: LockingScript): MandalaTokenDecoded {
|
|
87
|
+
const c = script.chunks
|
|
88
|
+
if (c.length !== 10) throw new Error('not a MandalaToken script: wrong chunk count')
|
|
89
|
+
const marker = c[0].data ?? []
|
|
90
|
+
if (c[0].op !== 1 || marker.length !== 1 || marker[0] !== MARKER) throw new Error('not a MandalaToken script: missing marker')
|
|
91
|
+
if (c[3].op !== OP.OP_2DROP || c[4].op !== OP.OP_DROP) throw new Error('not a MandalaToken script: bad drops')
|
|
92
|
+
if (c[5].op !== OP.OP_DUP || c[6].op !== OP.OP_HASH160 || c[8].op !== OP.OP_EQUALVERIFY || c[9].op !== OP.OP_CHECKSIG) {
|
|
93
|
+
throw new Error('not a MandalaToken script: bad P2PKH tail')
|
|
94
|
+
}
|
|
95
|
+
const assetId = decodeAssetId(vt(c[1].data))
|
|
96
|
+
const amount = decodeScriptNum(c[2].data ?? [])
|
|
97
|
+
if (!Number.isInteger(amount) || amount < 1) throw new Error('not a MandalaToken script: bad amount')
|
|
98
|
+
const pubKeyHash = vt(c[7].data)
|
|
99
|
+
if (pubKeyHash.length !== 20) throw new Error('not a MandalaToken script: bad pubKeyHash')
|
|
100
|
+
return { assetId, amount, pubKeyHash }
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/MultiPushDrop.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
Transaction,
|
|
13
13
|
PubKeyHex
|
|
14
14
|
} from '@bsv/sdk'
|
|
15
|
+
import { createMinimallyEncodedScriptChunk } from './mandala-encoding.js'
|
|
15
16
|
|
|
16
17
|
// Helper to ensure a value is not null or undefined
|
|
17
18
|
function verifyTruthy<T> (v: T | undefined | null, err?: string): T {
|
|
@@ -19,20 +20,6 @@ function verifyTruthy<T> (v: T | undefined | null, err?: string): T {
|
|
|
19
20
|
return v
|
|
20
21
|
}
|
|
21
22
|
|
|
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
23
|
/**
|
|
37
24
|
* Represents the decoded structure of a MultiPushDrop locking script.
|
|
38
25
|
*/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { MandalaAdmin } from '../MandalaAdmin.js'
|
|
2
|
+
|
|
3
|
+
describe('MandalaAdmin canonicalize/commitment', () => {
|
|
4
|
+
it('is insensitive to key ordering', () => {
|
|
5
|
+
const a = MandalaAdmin.canonicalize({ kind: 'issue', amount: 5, assetId: 'x.0' } as any)
|
|
6
|
+
const b = MandalaAdmin.canonicalize({ assetId: 'x.0', kind: 'issue', amount: 5 } as any)
|
|
7
|
+
expect(a).toBe(b)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('orders nested object keys', () => {
|
|
11
|
+
const s = MandalaAdmin.canonicalize({ kind: 'issue', meta: { z: 1, a: 2 } } as any)
|
|
12
|
+
expect(s).toBe('{"kind":"issue","meta":{"a":2,"z":1}}')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('produces a stable 64-hex commitment', () => {
|
|
16
|
+
const c = MandalaAdmin.commitment({ kind: 'register', assetId: 'x.0' })
|
|
17
|
+
expect(c).toMatch(/^[0-9a-f]{64}$/)
|
|
18
|
+
expect(c).toBe(MandalaAdmin.commitment({ assetId: 'x.0', kind: 'register' } as any))
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('derives a boundKey via getPublicKey with counterparty anyone', async () => {
|
|
22
|
+
const calls: any[] = []
|
|
23
|
+
const wallet: any = {
|
|
24
|
+
getPublicKey: async (args: any) => { calls.push(args); return { publicKey: '02' + 'a'.repeat(64) } }
|
|
25
|
+
}
|
|
26
|
+
const admin = new MandalaAdmin(wallet)
|
|
27
|
+
const details = { kind: 'issue', assetId: 'x.0', amount: 10 } as const
|
|
28
|
+
const { boundKey, keyID } = await admin.deriveBoundKey([2, 'mandala admin'], details)
|
|
29
|
+
expect(boundKey).toBe('02' + 'a'.repeat(64))
|
|
30
|
+
expect(keyID).toBe(MandalaAdmin.commitment(details))
|
|
31
|
+
expect(calls[0].counterparty).toBe('anyone')
|
|
32
|
+
expect(calls[0].keyID).toBe(keyID)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { MandalaAdmin } from '../MandalaAdmin.js'
|
|
2
|
+
import { PrivateKey, OP } from '@bsv/sdk'
|
|
3
|
+
|
|
4
|
+
describe('MandalaAdmin lock/decode', () => {
|
|
5
|
+
it('round-trips the boundKey and has the ! OP_DROP <key> OP_CHECKSIG shape', () => {
|
|
6
|
+
const boundKey = PrivateKey.fromRandom().toPublicKey().toString()
|
|
7
|
+
const admin = new MandalaAdmin({} as any)
|
|
8
|
+
const script = admin.lock(boundKey)
|
|
9
|
+
const ops = script.chunks.map(c => c.op)
|
|
10
|
+
expect(script.chunks[0].data).toEqual([0x21])
|
|
11
|
+
expect(ops[1]).toBe(OP.OP_DROP)
|
|
12
|
+
expect(ops[3]).toBe(OP.OP_CHECKSIG)
|
|
13
|
+
expect(MandalaAdmin.decode(script).boundKey).toBe(boundKey)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('decode throws on non-admin scripts', () => {
|
|
17
|
+
expect(() => MandalaAdmin.decode({ chunks: [{ op: 0x00 }] } as any)).toThrow()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('decode rejects a non-minimal (PUSHDATA1) marker encoding', () => {
|
|
21
|
+
const boundKey = PrivateKey.fromRandom().toPublicKey().toString()
|
|
22
|
+
const script = new MandalaAdmin({} as any).lock(boundKey)
|
|
23
|
+
script.chunks[0] = { op: 0x4c, data: [0x21] }
|
|
24
|
+
expect(() => MandalaAdmin.decode(script)).toThrow()
|
|
25
|
+
})
|
|
26
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { MandalaToken } from '../MandalaToken'
|
|
2
|
+
import { Hash, PrivateKey } from '@bsv/sdk'
|
|
3
|
+
|
|
4
|
+
describe('MandalaToken lock/decode', () => {
|
|
5
|
+
const assetId = `${'a'.repeat(64)}.0`
|
|
6
|
+
const pubKeyHash = Hash.hash160(PrivateKey.fromRandom().toPublicKey().encode(true) as number[])
|
|
7
|
+
|
|
8
|
+
it('builds a script that decodes back to its inputs', () => {
|
|
9
|
+
const script = new MandalaToken().lock(assetId, 1000, pubKeyHash)
|
|
10
|
+
const decoded = MandalaToken.decode(script)
|
|
11
|
+
expect(decoded.assetId).toBe(assetId)
|
|
12
|
+
expect(decoded.amount).toBe(1000)
|
|
13
|
+
expect(decoded.pubKeyHash).toEqual(pubKeyHash)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('produces a P2PKH tail (OP_DUP OP_HASH160 ... OP_EQUALVERIFY OP_CHECKSIG)', () => {
|
|
17
|
+
const script = new MandalaToken().lock(assetId, 1, pubKeyHash)
|
|
18
|
+
const ops = script.chunks.map(c => c.op)
|
|
19
|
+
expect(ops.slice(-5)).toEqual([0x76, 0xa9, 20, 0x88, 0xac])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('throws when decoding a non-Mandala script', () => {
|
|
23
|
+
expect(() => MandalaToken.decode({ chunks: [{ op: 0x00 }] } as any)).toThrow()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('decode throws when the amount chunk is empty/zero', () => {
|
|
27
|
+
const assetId = `${'a'.repeat(64)}.0`
|
|
28
|
+
const pkh = new Array(20).fill(1)
|
|
29
|
+
const script = new MandalaToken().lock(assetId, 5, pkh)
|
|
30
|
+
// Replace the amount push (chunk index 2) with an empty (OP_0) push.
|
|
31
|
+
script.chunks[2] = { op: 0 }
|
|
32
|
+
expect(() => MandalaToken.decode(script)).toThrow()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('decode rejects a non-minimal (PUSHDATA1) marker encoding', () => {
|
|
36
|
+
const assetId = `${'a'.repeat(64)}.0`
|
|
37
|
+
const pkh = new Array(20).fill(1)
|
|
38
|
+
const script = new MandalaToken().lock(assetId, 5, pkh)
|
|
39
|
+
// Re-encode the marker push (chunk 0) as PUSHDATA1 of the same byte.
|
|
40
|
+
script.chunks[0] = { op: 0x4c, data: [0x21] }
|
|
41
|
+
expect(() => MandalaToken.decode(script)).toThrow()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { MandalaToken } from '../MandalaToken'
|
|
2
|
+
import { PrivateKey, Hash, Transaction, P2PKH } from '@bsv/sdk'
|
|
3
|
+
|
|
4
|
+
describe('MandalaToken unlock', () => {
|
|
5
|
+
const assetId = `${'b'.repeat(64)}.1`
|
|
6
|
+
|
|
7
|
+
it('signs a spend whose script verifies against the source output', async () => {
|
|
8
|
+
const priv = PrivateKey.fromRandom()
|
|
9
|
+
const pubKeyHash = Hash.hash160(priv.toPublicKey().encode(true) as number[])
|
|
10
|
+
const lockingScript = new MandalaToken().lock(assetId, 5, pubKeyHash)
|
|
11
|
+
|
|
12
|
+
const sourceTx = new Transaction()
|
|
13
|
+
sourceTx.addOutput({ lockingScript, satoshis: 1 })
|
|
14
|
+
|
|
15
|
+
const spendTx = new Transaction()
|
|
16
|
+
spendTx.addInput({ sourceTransaction: sourceTx, sourceOutputIndex: 0, sequence: 0xffffffff })
|
|
17
|
+
spendTx.addOutput({ lockingScript: new P2PKH().lock(pubKeyHash), satoshis: 1 })
|
|
18
|
+
|
|
19
|
+
const unlocker = new MandalaToken().unlock(priv)
|
|
20
|
+
const unlockingScript = await unlocker.sign(spendTx, 0)
|
|
21
|
+
spendTx.inputs[0].unlockingScript = unlockingScript
|
|
22
|
+
|
|
23
|
+
// Two pushes: signature then pubkey.
|
|
24
|
+
expect(unlockingScript.chunks.length).toBe(2)
|
|
25
|
+
expect(unlockingScript.chunks[1].data?.length).toBe(33)
|
|
26
|
+
// estimateLength uses optional parameters to satisfy ScriptTemplateUnlock interface
|
|
27
|
+
// while supporting no-argument calls for backward compatibility
|
|
28
|
+
// @ts-expect-error - SDK interface requires params, but implementation has optional params
|
|
29
|
+
expect(await unlocker.estimateLength()).toBeGreaterThan(100)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { MandalaToken, MandalaAdmin } from '../../mod.js'
|
|
2
|
+
|
|
3
|
+
describe('package exports', () => {
|
|
4
|
+
it('exposes the Mandala templates from the package entrypoint', () => {
|
|
5
|
+
expect(typeof MandalaToken).toBe('function')
|
|
6
|
+
expect(typeof MandalaAdmin).toBe('function')
|
|
7
|
+
expect(typeof MandalaAdmin.canonicalize).toBe('function')
|
|
8
|
+
})
|
|
9
|
+
})
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
encodeScriptNum, decodeScriptNum, encodeAssetId, decodeAssetId, MARKER
|
|
3
|
+
} from '../mandala-encoding'
|
|
4
|
+
|
|
5
|
+
describe('mandala-encoding', () => {
|
|
6
|
+
it('round-trips small and large script numbers', () => {
|
|
7
|
+
for (const n of [0, 1, 16, 127, 128, 255, 256, 1000, 0x7fffffff]) {
|
|
8
|
+
expect(decodeScriptNum(encodeScriptNum(n))).toBe(n)
|
|
9
|
+
}
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('encodes zero as an empty array', () => {
|
|
13
|
+
expect(encodeScriptNum(0)).toEqual([])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('round-trips an assetId outpoint string', () => {
|
|
17
|
+
const txid = 'a'.repeat(64)
|
|
18
|
+
const assetId = `${txid}.3`
|
|
19
|
+
const bytes = encodeAssetId(assetId)
|
|
20
|
+
expect(bytes.length).toBe(36)
|
|
21
|
+
expect(decodeAssetId(bytes)).toBe(assetId)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('round-trips an assetId with a high-bit vout (>= 2^31)', () => {
|
|
25
|
+
const assetId = `${'a'.repeat(64)}.4294967295`
|
|
26
|
+
expect(decodeAssetId(encodeAssetId(assetId))).toBe(assetId)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('exposes the ! marker', () => {
|
|
30
|
+
expect(MARKER).toBe(0x21)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Utils } from '@bsv/sdk'
|
|
2
|
+
|
|
3
|
+
export const MARKER = 0x21
|
|
4
|
+
|
|
5
|
+
export const createMinimallyEncodedScriptChunk = (
|
|
6
|
+
data: number[]
|
|
7
|
+
): { op: number, data?: number[] } => {
|
|
8
|
+
if (data.length === 0) return { op: 0 }
|
|
9
|
+
if (data.length === 1 && data[0] === 0) return { op: 0 }
|
|
10
|
+
if (data.length === 1 && data[0] > 0 && data[0] <= 16) return { op: 0x50 + data[0] }
|
|
11
|
+
if (data.length === 1 && data[0] === 0x81) return { op: 0x4f }
|
|
12
|
+
if (data.length <= 75) return { op: data.length, data }
|
|
13
|
+
if (data.length <= 255) return { op: 0x4c, data }
|
|
14
|
+
if (data.length <= 65535) return { op: 0x4d, data }
|
|
15
|
+
return { op: 0x4e, data }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Bitcoin script number: minimal little-endian, sign in the high bit of the last byte.
|
|
19
|
+
export const encodeScriptNum = (value: number): number[] => {
|
|
20
|
+
if (value === 0) return []
|
|
21
|
+
const negative = value < 0
|
|
22
|
+
let abs = Math.abs(value)
|
|
23
|
+
const result: number[] = []
|
|
24
|
+
while (abs > 0) {
|
|
25
|
+
result.push(abs & 0xff)
|
|
26
|
+
abs = Math.floor(abs / 256)
|
|
27
|
+
}
|
|
28
|
+
if ((((result.at(-1)) ?? 0) & 0x80) !== 0) {
|
|
29
|
+
result.push(negative ? 0x80 : 0x00)
|
|
30
|
+
} else if (negative) {
|
|
31
|
+
result[result.length - 1] |= 0x80
|
|
32
|
+
}
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const decodeScriptNum = (data: number[]): number => {
|
|
37
|
+
if (data.length === 0) return 0
|
|
38
|
+
let result = 0
|
|
39
|
+
for (let i = 0; i < data.length; i++) {
|
|
40
|
+
result += (i === data.length - 1 ? (data[i] & 0x7f) : data[i]) * Math.pow(256, i)
|
|
41
|
+
}
|
|
42
|
+
if ((((data.at(-1)) ?? 0) & 0x80) !== 0) result = -result
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const encodeAssetId = (assetId: string): number[] => {
|
|
47
|
+
const dot = assetId.lastIndexOf('.')
|
|
48
|
+
if (dot === -1) throw new Error('assetId must be "<txid>.<vout>"')
|
|
49
|
+
const txid = assetId.slice(0, dot)
|
|
50
|
+
const vout = Number(assetId.slice(dot + 1))
|
|
51
|
+
if (txid.length !== 64) throw new Error('assetId txid must be 32 bytes (64 hex chars)')
|
|
52
|
+
if (!Number.isInteger(vout) || vout < 0) throw new Error('assetId vout must be a non-negative integer')
|
|
53
|
+
const txidBytes = Utils.toArray(txid, 'hex')
|
|
54
|
+
const voutBytes = [vout & 0xff, (vout >> 8) & 0xff, (vout >> 16) & 0xff, (vout >> 24) & 0xff]
|
|
55
|
+
return [...txidBytes, ...voutBytes]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const decodeAssetId = (bytes: number[]): string => {
|
|
59
|
+
if (bytes.length !== 36) throw new Error('assetId bytes must be exactly 36 bytes')
|
|
60
|
+
const txid = Utils.toHex(bytes.slice(0, 32))
|
|
61
|
+
const v = bytes.slice(32)
|
|
62
|
+
const vout = (v[0] + (v[1] << 8) + (v[2] << 16) + (v[3] << 24)) >>> 0
|
|
63
|
+
return `${txid}.${vout}`
|
|
64
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Transaction, TransactionSignature } from '@bsv/sdk'
|
|
2
|
+
|
|
3
|
+
export type SignOutputs = 'all' | 'none' | 'single'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Builds the BIP143 sighash preimage and scope shared by the Mandala unlock templates.
|
|
7
|
+
*/
|
|
8
|
+
export function buildSighashPreimage (
|
|
9
|
+
tx: Transaction,
|
|
10
|
+
inputIndex: number,
|
|
11
|
+
signOutputs: SignOutputs,
|
|
12
|
+
anyoneCanPay: boolean
|
|
13
|
+
): { preimage: number[], scope: number } {
|
|
14
|
+
let scope = TransactionSignature.SIGHASH_FORKID
|
|
15
|
+
if (signOutputs === 'all') scope |= TransactionSignature.SIGHASH_ALL
|
|
16
|
+
else if (signOutputs === 'none') scope |= TransactionSignature.SIGHASH_NONE
|
|
17
|
+
else if (signOutputs === 'single') scope |= TransactionSignature.SIGHASH_SINGLE
|
|
18
|
+
if (anyoneCanPay) scope |= TransactionSignature.SIGHASH_ANYONECANPAY
|
|
19
|
+
|
|
20
|
+
const input = tx.inputs[inputIndex]
|
|
21
|
+
const sourceTXID = input.sourceTXID ?? input.sourceTransaction?.id('hex')
|
|
22
|
+
const sourceOutput = input.sourceTransaction?.outputs[input.sourceOutputIndex]
|
|
23
|
+
if (sourceTXID == null) throw new Error('sourceTXID or sourceTransaction required')
|
|
24
|
+
if (sourceOutput?.satoshis == null) throw new Error('source satoshis required')
|
|
25
|
+
if (sourceOutput.lockingScript == null) throw new Error('source lockingScript required')
|
|
26
|
+
|
|
27
|
+
const preimage = TransactionSignature.format({
|
|
28
|
+
sourceTXID,
|
|
29
|
+
sourceOutputIndex: input.sourceOutputIndex,
|
|
30
|
+
sourceSatoshis: sourceOutput.satoshis,
|
|
31
|
+
transactionVersion: tx.version,
|
|
32
|
+
otherInputs: tx.inputs.filter((_, i) => i !== inputIndex),
|
|
33
|
+
inputIndex,
|
|
34
|
+
outputs: tx.outputs,
|
|
35
|
+
inputSequence: input.sequence ?? 0xffffffff,
|
|
36
|
+
subscript: sourceOutput.lockingScript,
|
|
37
|
+
lockTime: tx.lockTime,
|
|
38
|
+
scope
|
|
39
|
+
})
|
|
40
|
+
return { preimage, scope }
|
|
41
|
+
}
|