From 250e1d58aabda2999b05bebae39fd4ee3ad9470b Mon Sep 17 00:00:00 2001 From: AhmadReza6610 <104721567+AhmadReza6610@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:18:35 +0330 Subject: [PATCH] fix: use random bytes for randomUtxo/randomToken/randomNFT ids (fixes #471) randomUtxo, randomToken and randomNFT derived txids, token categories and NFT commitments from randomInt(), which has only 10,000 values, so tests with many random UTXOs or tokens got shared outpoints and categories. Use crypto.getRandomValues instead: 32 bytes for txids and categories, 4 bytes for NFT commitments. Amounts still use randomInt(). Co-Authored-By: Claude Opus 5.5 --- packages/cashscript/src/utils.ts | 14 +++++---- packages/cashscript/test/util.test.ts | 41 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/cashscript/src/utils.ts b/packages/cashscript/src/utils.ts index e8a2744bf..f9ffc24ba 100644 --- a/packages/cashscript/src/utils.ts +++ b/packages/cashscript/src/utils.ts @@ -13,14 +13,12 @@ import { isHex, bigIntToCompactUint, NonFungibleTokenCapability, - bigIntToVmNumber, SigningSerializationFlag, } from '@bitauth/libauth'; import { encodeInt, hash160, hash256, - sha256, Op, Script, scriptToBytecode, @@ -419,6 +417,10 @@ export function getNetworkPrefix(network: string): 'bitcoincash' | 'bchtest' | ' const randomInt = (): bigint => BigInt(Math.floor(Math.random() * 10000)); +// Transaction ids, token categories and NFT commitments are random bytes, so tests that create many random UTXOs +// or tokens don't end up with colliding outpoints or categories +const randomBytesHex = (length: number): string => binToHex(crypto.getRandomValues(new Uint8Array(length))); + /** * Generate a random `Utxo` for use with `MockNetworkProvider.addUtxo` in tests and examples. * Fields can be overridden by passing a `defaults` object. @@ -427,7 +429,7 @@ const randomInt = (): bigint => BigInt(Math.floor(Math.random() * 10000)); * @returns A synthetic UTXO with random `txid`, `vout`, and `satoshis`. */ export const randomUtxo = >(defaults?: T): Utxo & T => ({ - txid: binToHex(sha256(bigIntToVmNumber(randomInt()))), + txid: randomBytesHex(32), vout: Math.floor(Math.random() * 10), satoshis: 100_000n + randomInt(), ...defaults, @@ -442,7 +444,7 @@ export const randomUtxo = >(defaults?: T): Utxo & T => ( */ export const randomToken = (defaults?: Partial): TokenDetails => ({ ...{ - category: binToHex(sha256(bigIntToVmNumber(randomInt()))), + category: randomBytesHex(32), amount: 100_000n + randomInt(), }, ...defaults, @@ -457,10 +459,10 @@ export const randomToken = (defaults?: Partial): TokenDetails => ( */ export const randomNFT = (defaults?: Partial): TokenDetails => ({ ...{ - category: binToHex(sha256(bigIntToVmNumber(randomInt()))), + category: randomBytesHex(32), amount: 0n, nft: { - commitment: binToHex(sha256(bigIntToVmNumber(randomInt()))).slice(0, 8), + commitment: randomBytesHex(4), capability: NonFungibleTokenCapability.none, }, }, diff --git a/packages/cashscript/test/util.test.ts b/packages/cashscript/test/util.test.ts index b09cb64c1..437b7574e 100644 --- a/packages/cashscript/test/util.test.ts +++ b/packages/cashscript/test/util.test.ts @@ -9,6 +9,9 @@ import { scriptToAddress, createUnlockingBytecode, getInputSize, + randomUtxo, + randomToken, + randomNFT, } from '../src/utils.js'; import { Network } from '../src/interfaces.js'; import { alicePkh, alicePub } from './fixture/vars.js'; @@ -115,4 +118,42 @@ describe('utils', () => { expect(regtestAddressP2sh20Tokens).toEqual(expectedRegtestAddressP2sh20Tokens); }); }); + + describe('random test helpers', () => { + // With the old 10,000 possible values, 2,000 draws were all but certain to collide + const draws = 2_000; + const distinct = (values: string[]): number => new Set(values).size; + + it('should give every random UTXO a distinct outpoint', () => { + const utxos = Array.from({ length: draws }, () => randomUtxo()); + + expect(distinct(utxos.map((utxo) => `${utxo.txid}:${utxo.vout}`))).toBe(draws); + utxos.forEach((utxo) => expect(utxo.txid).toMatch(/^[0-9a-f]{64}$/)); + }); + + it('should give every random token a distinct category', () => { + const categories = Array.from({ length: draws }, () => randomToken().category); + + expect(distinct(categories)).toBe(draws); + categories.forEach((category) => expect(category).toMatch(/^[0-9a-f]{64}$/)); + }); + + it('should give random NFTs distinct categories and 4-byte commitments', () => { + const nfts = Array.from({ length: draws }, () => randomNFT()); + const commitments = nfts.map((nft) => nft.nft!.commitment); + + expect(distinct(nfts.map((nft) => nft.category))).toBe(draws); + commitments.forEach((commitment) => expect(commitment).toMatch(/^[0-9a-f]{8}$/)); + // 4 random bytes can still collide by chance (~0.05% for 2,000 draws), so allow a few repeats + expect(distinct(commitments)).toBeGreaterThan(draws - 5); + }); + + it('should still let defaults override the random fields', () => { + const txid = 'ab'.repeat(32); + + expect(randomUtxo({ txid, vout: 3 })).toMatchObject({ txid, vout: 3 }); + expect(randomToken({ category: txid }).category).toBe(txid); + expect(randomNFT({ category: txid }).category).toBe(txid); + }); + }); });