@bsv/sdk
Version:
BSV Blockchain Software Development Kit
556 lines (504 loc) • 14.7 kB
text/typescript
import PrivateKey from '../../primitives/PrivateKey'
import { hash160, hash256 } from '../../primitives/Hash'
import Curve from '../../primitives/Curve'
import Spend from '../../script/Spend'
import P2PKH from '../../script/templates/P2PKH'
import RPuzzle from '../../script/templates/RPuzzle'
import Transaction from '../../transaction/Transaction'
import LockingScript from '../../script/LockingScript'
import UnlockingScript from '../../script/UnlockingScript'
import MerklePath from '../../transaction/MerklePath'
import ChainTracker from '../../transaction/ChainTracker'
import spendValid from './spend.valid.vectors'
import Script from '../../script/Script'
import BigNumber from '../../primitives/BigNumber'
import ScriptChunk from '../../script/ScriptChunk'
import OP from '../../script/OP'
export class MockChain implements ChainTracker {
mock: { blockheaders: string[] }
constructor(mock: { blockheaders: string[] }) {
this.mock = mock
}
addBlock(merkleRoot: string) {
this.mock.blockheaders.push(merkleRoot)
}
async isValidRootForHeight(root: string, height: number): Promise<boolean> {
return this.mock.blockheaders[height] === root
}
async currentHeight(): Promise<number> {
return this.mock.blockheaders.length
}
}
const ZERO_TXID = '0'.repeat(64)
const cloneChunks = (chunks: ScriptChunk[]): ScriptChunk[] =>
chunks.map((chunk) => ({
op: chunk.op,
data: Array.isArray(chunk.data) ? chunk.data.slice() : undefined
}))
const lockingScriptFromAsm = (asm: string): LockingScript => {
const parsed = Script.fromASM(asm)
return new LockingScript(cloneChunks(parsed.chunks))
}
const pushChunkFromBytes = (bytes: number[]): ScriptChunk => {
if (bytes.length === 0) {
return { op: OP.OP_0, data: [] }
}
if (bytes.length === 1) {
if (bytes[0] >= 1 && bytes[0] <= 16) {
return { op: OP.OP_1 + (bytes[0] - 1) }
}
if (bytes[0] === 0x81) {
return { op: OP.OP_1NEGATE }
}
}
let op: number
if (bytes.length < OP.OP_PUSHDATA1) op = bytes.length
else if (bytes.length < Math.pow(2, 8)) op = OP.OP_PUSHDATA1
else if (bytes.length < Math.pow(2, 16)) op = OP.OP_PUSHDATA2
else op = OP.OP_PUSHDATA4
return {
op,
data: bytes.slice()
}
}
const createUnlockingScriptFromPushes = (pushes: number[][]): UnlockingScript =>
new UnlockingScript(pushes.map(pushChunkFromBytes))
const createSpendWithPushes = (lockingAsm: string, unlockingPushes: number[][]): Spend =>
new Spend({
sourceTXID: ZERO_TXID,
sourceOutputIndex: 0,
sourceSatoshis: 1,
lockingScript: lockingScriptFromAsm(lockingAsm),
transactionVersion: 1,
otherInputs: [],
outputs: [],
inputIndex: 0,
unlockingScript: createUnlockingScriptFromPushes(unlockingPushes),
inputSequence: 0xffffffff,
lockTime: 0
})
const scriptNumBytes = (value: bigint): number[] => new BigNumber(value).toScriptNum()
describe('Spend', () => {
it('Successfully validates a P2PKH spend', async () => {
const privateKey = new PrivateKey(1)
const publicKey = privateKey.toPublicKey()
const hash = publicKey.toHash()
const p2pkh = new P2PKH()
const lockingScript = p2pkh.lock(hash)
const satoshis = 1
const unlockingTemplate = p2pkh.unlock(privateKey)
const sourceTx = new Transaction(
1,
[],
[
{
lockingScript,
satoshis
}
],
0
)
const spendTx = new Transaction(
1,
[
{
sourceTransaction: sourceTx,
sourceOutputIndex: 0,
sequence: 0xffffffff
}
],
[],
0
)
const unlockingScript = await unlockingTemplate.sign(spendTx, 0)
const spend = new Spend({
sourceTXID: sourceTx.id('hex'),
sourceOutputIndex: 0,
sourceSatoshis: satoshis,
lockingScript,
transactionVersion: 1,
otherInputs: [],
inputIndex: 0,
unlockingScript,
outputs: [],
inputSequence: 0xffffffff,
lockTime: 0
})
const valid = spend.validate()
expect(valid).toBe(true)
})
it('Fails to verify a P2PKH spend with the wrong key', async () => {
const privateKey = new PrivateKey(1)
const publicKey = privateKey.toPublicKey()
const wrongPrivateKey = new PrivateKey(2)
const hash = publicKey.toHash()
const p2pkh = new P2PKH()
const lockingScript = p2pkh.lock(hash)
const satoshis = 1
const unlockingTemplate = p2pkh.unlock(wrongPrivateKey)
const sourceTx = new Transaction(
1,
[],
[
{
lockingScript,
satoshis
}
],
0
)
const spendTx = new Transaction(
1,
[
{
sourceTransaction: sourceTx,
sourceOutputIndex: 0,
sequence: 0xffffffff
}
],
[],
0
)
const unlockingScript = await unlockingTemplate.sign(spendTx, 0)
const spend = new Spend({
sourceTXID: sourceTx.id('hex'),
sourceOutputIndex: 0,
sourceSatoshis: satoshis,
lockingScript,
transactionVersion: 1,
otherInputs: [],
inputIndex: 0,
unlockingScript,
outputs: [],
inputSequence: 0xffffffff,
lockTime: 0
})
expect(() => spend.validate()).toThrow()
})
it('Successfully validates an R-puzzle spend', async () => {
const k = new PrivateKey(2)
const c = new Curve()
let r = c.g.mul(k).x?.umod(c.n)?.toArray()
if (r !== null && r !== undefined) {
r = r[0] > 127 ? [0, ...r] : r
}
const puz = new RPuzzle()
const lockingScript = puz.lock(r ?? [])
const satoshis = 1
// ✅ Fix: Ensure privateKey is valid and within range
const privateKey = PrivateKey.fromRandom()
const unlockingTemplate = puz.unlock(k, privateKey)
const sourceTx = new Transaction(
1,
[],
[
{
lockingScript,
satoshis
}
],
0
)
const spendTx = new Transaction(
1,
[
{
sourceTransaction: sourceTx,
sourceOutputIndex: 0,
sequence: 0xffffffff
}
],
[],
0
)
const unlockingScript = await unlockingTemplate.sign(spendTx, 0)
const spend = new Spend({
sourceTXID: sourceTx.id('hex'),
sourceOutputIndex: 0,
sourceSatoshis: satoshis,
lockingScript,
transactionVersion: 1,
otherInputs: [],
inputIndex: 0,
unlockingScript,
outputs: [],
inputSequence: 0xffffffff,
lockTime: 0
})
const valid = spend.validate()
expect(valid).toBe(true)
})
it('maintains endianness when shifting right', () => {
const spend = createSpendWithPushes(
'OP_1 OP_RSHIFT 0080 OP_EQUAL',
[[0x01, 0x00]]
)
expect(spend.validate()).toBe(true)
})
it('maintains endianness when shifting left', () => {
const spend = createSpendWithPushes(
'OP_1 OP_LSHIFT 0100 OP_EQUAL',
[[0x00, 0x80]]
)
expect(spend.validate()).toBe(true)
})
it('Successfully validates an R-puzzle spend (HASH256)', async () => {
const k = new PrivateKey(2)
const c = new Curve()
let r = c.g.mul(k).x?.umod(c.n)?.toArray()
if (r !== null && r !== undefined) {
r = r[0] > 127 ? [0, ...r] : r
r = hash256(r)
}
const puz = new RPuzzle('HASH256')
const lockingScript = puz.lock(r ?? [])
const satoshis = 1
// ✅ Fix: Ensure privateKey is valid and within range
const privateKey = PrivateKey.fromRandom()
const unlockingTemplate = puz.unlock(k, privateKey)
const sourceTx = new Transaction(
1,
[],
[
{
lockingScript,
satoshis
}
],
0
)
const spendTx = new Transaction(
1,
[
{
sourceTransaction: sourceTx,
sourceOutputIndex: 0,
sequence: 0xffffffff
}
],
[],
0
)
const unlockingScript = await unlockingTemplate.sign(spendTx, 0)
const spend = new Spend({
sourceTXID: sourceTx.id('hex'),
sourceOutputIndex: 0,
sourceSatoshis: satoshis,
lockingScript,
transactionVersion: 1,
otherInputs: [],
inputIndex: 0,
unlockingScript,
outputs: [],
inputSequence: 0xffffffff,
lockTime: 0
})
const valid = spend.validate()
expect(valid).toBe(true)
})
it('Fails to validate an R-puzzle spend with the wrong K value', async () => {
const k = new PrivateKey(2)
const wrongK = new PrivateKey(5)
const c = new Curve()
let r = c.g.mul(k).x?.umod(c.n)?.toArray()
if (r !== null && r !== undefined) {
r = r[0] > 127 ? [0, ...r] : r
r = hash256(r)
}
const puz = new RPuzzle('HASH256')
const lockingScript = puz.lock(r ?? [])
const satoshis = 1
// ✅ Fix: Ensure privateKey is valid and within range
const privateKey = PrivateKey.fromRandom()
const unlockingTemplate = puz.unlock(wrongK, privateKey)
const sourceTx = new Transaction(
1,
[],
[
{
lockingScript,
satoshis
}
],
0
)
const spendTx = new Transaction(
1,
[
{
sourceTransaction: sourceTx,
sourceOutputIndex: 0,
sequence: 0xffffffff
}
],
[],
0
)
const unlockingScript = await unlockingTemplate.sign(spendTx, 0)
const spend = new Spend({
sourceTXID: sourceTx.id('hex'),
sourceOutputIndex: 0,
sourceSatoshis: satoshis,
lockingScript,
transactionVersion: 1,
otherInputs: [],
inputIndex: 0,
unlockingScript,
outputs: [],
inputSequence: 0xffffffff,
lockTime: 0
})
expect(() => spend.validate()).toThrow()
})
it('Fails to validate an R-puzzle spend with the wrong hash', async () => {
const k = new PrivateKey(2)
const c = new Curve()
let r = c.g.mul(k).x?.umod(c.n)?.toArray()
if (r !== null && r !== undefined) {
r = r[0] > 127 ? [0, ...r] : r
r = hash160(r)
}
const puz = new RPuzzle('HASH256')
const lockingScript = puz.lock(r ?? [])
const satoshis = 1
// ✅ Fix: Ensure privateKey is valid and within range
const privateKey = PrivateKey.fromRandom()
const unlockingTemplate = puz.unlock(k, privateKey)
const sourceTx = new Transaction(
1,
[],
[
{
lockingScript,
satoshis
}
],
0
)
const spendTx = new Transaction(
1,
[
{
sourceTransaction: sourceTx,
sourceOutputIndex: 0,
sequence: 0xffffffff
}
],
[],
0
)
const unlockingScript = await unlockingTemplate.sign(spendTx, 0)
const spend = new Spend({
sourceTXID: sourceTx.id('hex'),
sourceOutputIndex: 0,
sourceSatoshis: satoshis,
lockingScript,
transactionVersion: 1,
otherInputs: [],
inputIndex: 0,
unlockingScript,
outputs: [],
inputSequence: 0xffffffff,
lockTime: 0
})
expect(() => spend.validate()).toThrow()
})
for (let i = 0; i < spendValid.length; i++) {
const a = spendValid[i]
if (a.length === 1) {
continue
}
it(a[2], () => {
const spend = new Spend({
sourceTXID:
'0000000000000000000000000000000000000000000000000000000000000000',
sourceOutputIndex: 0,
sourceSatoshis: 1,
lockingScript: LockingScript.fromHex(a[1]),
transactionVersion: 1,
otherInputs: [],
outputs: [],
inputIndex: 0,
unlockingScript: UnlockingScript.fromHex(a[0]),
inputSequence: 0xffffffff,
lockTime: 0
})
expect(spend.validate()).toBe(true)
})
}
describe('bigint stack operand handling', () => {
it('OP_PICK rejects indexes that exceed the stack length without tripping JS safe-integer limits', () => {
const spend = createSpendWithPushes('OP_PICK', [
[0x01],
scriptNumBytes((1n << 60n) + 5n)
])
expect(() => spend.validate()).toThrow('OP_PICK requires the top stack element to be 0 or a positive number less than the current size of the stack.')
})
it('OP_SPLIT surfaces its range error for very large positions', () => {
const spend = createSpendWithPushes('OP_SPLIT', [
[0x01, 0x02],
scriptNumBytes(1n << 60n)
])
expect(() => spend.validate()).toThrow('OP_SPLIT requires the first stack item to be a non-negative number less than or equal to the size of the second-from-top stack item.')
})
it('OP_NUM2BIN enforces the max element size before reaching JS number limits', () => {
const spend = createSpendWithPushes('OP_NUM2BIN', [
[0x01],
scriptNumBytes(1n << 60n)
])
expect(() => spend.validate()).toThrow("It's not currently possible to push data larger than 1073741824 bytes or negative size.")
})
it('OP_LSHIFT accepts shift counts larger than Number.MAX_SAFE_INTEGER when no shift work is required', () => {
const spend = createSpendWithPushes('OP_LSHIFT OP_DROP OP_TRUE', [
[],
scriptNumBytes(1n << 60n)
])
expect(spend.validate()).toBe(true)
})
})
it('Successfully validates a spend where sequence is set to undefined', async () => {
const sourceTransaction = new Transaction(
1,
[{
sourceTXID: '0000000000000000000000000000000000000000000000000000000000000000',
sourceOutputIndex: 0,
unlockingScript: Script.fromASM('OP_TRUE'),
sequence: 0xffffffff
}],
[
{
lockingScript: Script.fromASM('OP_NOP'),
satoshis: 2
}
],
0
)
const txid = sourceTransaction.id('hex')
sourceTransaction.merklePath = MerklePath.fromCoinbaseTxidAndHeight(txid, 0)
const chain = new MockChain({ blockheaders: [] })
chain.addBlock(txid)
const spendTx = new Transaction(
1,
[
{
unlockingScript: Script.fromASM('OP_TRUE'),
sourceTransaction,
sourceOutputIndex: 0
}
],
[{
lockingScript: Script.fromASM('OP_NOP'),
satoshis: 1
}],
0
)
const valid = await spendTx.verify(chain)
expect(valid).toBe(true)
const b = spendTx.toBinary()
const t = Transaction.fromBinary(b)
expect(t.inputs[0].sequence).toBe(0xffffffff)
const b2 = spendTx.toEF()
const t2 = Transaction.fromEF(b2)
expect(t2.inputs[0].sequence).toBe(0xffffffff)
})
})