From ba1a2802b8628d219659eaaced0b13ba874059d2 Mon Sep 17 00:00:00 2001 From: notTanveer Date: Fri, 13 Sep 2024 20:08:48 +0530 Subject: [PATCH] test: e2e --- config/e2e.config.yaml | 2 +- e2e/helpers/api.helper.ts | 4 +- e2e/helpers/common.helper.ts | 100 +++++++++ e2e/helpers/docker/docker-compose.yml | 3 +- e2e/helpers/rpc.helper.ts | 135 ++++++++++++ e2e/helpers/wallet.helper.ts | 126 +++++++++++ e2e/indexer.e2e-spec.ts | 107 ++++++++++ package-lock.json | 289 ++++++++++++++++++++++++++ package.json | 4 + src/indexer/indexer.service.ts | 46 ++-- 10 files changed, 791 insertions(+), 25 deletions(-) create mode 100644 e2e/helpers/common.helper.ts create mode 100644 e2e/helpers/rpc.helper.ts create mode 100644 e2e/helpers/wallet.helper.ts create mode 100644 e2e/indexer.e2e-spec.ts diff --git a/config/e2e.config.yaml b/config/e2e.config.yaml index c76b343..9906f32 100644 --- a/config/e2e.config.yaml +++ b/config/e2e.config.yaml @@ -11,6 +11,6 @@ providerType: BITCOIN_CORE_RPC bitcoinCore: protocol: http rpcHost: localhost - rpcPass: polarpass + rpcPass: password rpcUser: polaruser rpcPort: 18443 diff --git a/e2e/helpers/api.helper.ts b/e2e/helpers/api.helper.ts index 3356cf3..010f34b 100644 --- a/e2e/helpers/api.helper.ts +++ b/e2e/helpers/api.helper.ts @@ -13,12 +13,12 @@ export class ApiHelper { this.baseUrl = `http://localhost:${config.app.port}`; } - async get(path: string, params?: any) { + async get(path: string, params?: AxiosRequestConfig) { return this.makeRequest({ method: 'get', url: `${this.baseUrl}${path}`, - params, validateStatus: () => true, + ...params, }); } diff --git a/e2e/helpers/common.helper.ts b/e2e/helpers/common.helper.ts new file mode 100644 index 0000000..b7bd0fd --- /dev/null +++ b/e2e/helpers/common.helper.ts @@ -0,0 +1,100 @@ +import { Payment, Transaction } from 'bitcoinjs-lib'; +import { IndexerService } from '@/indexer/indexer.service'; +import { SATS_PER_BTC } from '@/common/constants'; +import * as currency from 'currency.js'; +import { varIntSize } from '@/common/common'; + +export function generateScanTweak( + transaction: Transaction, + outputs: Payment[], + indexerService: IndexerService, +): string { + const txid = transaction.getId(); + + const txin = transaction.ins.map((input, index) => { + const isWitness = transaction.hasWitnesses(); + + return { + txid: input.hash.reverse().toString('hex'), + vout: input.index, + scriptSig: isWitness ? '' : input.script.toString('hex'), + witness: isWitness + ? input.witness.map((v) => v.toString('hex')) + : undefined, + prevOutScript: outputs[index].output.toString('hex'), + }; + }); + const txout = transaction.outs.map((output) => ({ + scriptPubKey: output.script.toString('hex'), + value: output.value, + })); + + const [scanTweak] = indexerService.computeScanTweak(txid, txin, txout); + + return scanTweak.toString('hex'); +} + +export const readVarInt = (data: Buffer, cursor: number): number => { + const firstByte = data.readUInt8(cursor); + if (firstByte < 0xfd) return firstByte; + else if (firstByte === 0xfd) return data.readUInt16LE(cursor + 1); + else if (firstByte === 0xfe) return data.readUInt32LE(cursor + 1); + else return Number(data.readBigUInt64LE(cursor + 1)); +}; + +export interface SilentBlockTransaction { + txid: string; + outputs: { + value: number; + pubkey: string; + vout: number; + }[]; + scanTweak: string; +} + +export interface SilentBlock { + type: number; + transactions: SilentBlockTransaction[]; +} + +export const parseSilentBlock = (data: Buffer): SilentBlock => { + const type = data.readUInt8(0); + const transactions: SilentBlockTransaction[] = []; + + let cursor = 1; + const count = readVarInt(data, cursor); + cursor += varIntSize(count); + + for (let i = 0; i < count; i++) { + const txid = data.subarray(cursor, cursor + 32).toString('hex'); + cursor += 32; + + const outputs = []; + const outputCount = readVarInt(data, cursor); + cursor += varIntSize(outputCount); + + for (let j = 0; j < outputCount; j++) { + const value = Number(data.readBigUInt64BE(cursor)); + cursor += 8; + + const pubkey = data.subarray(cursor, cursor + 32).toString('hex'); + cursor += 32; + + const vout = data.readUint32BE(cursor); + cursor += 4; + + outputs.push({ value, pubkey, vout }); + } + + const scanTweak = data.subarray(cursor, cursor + 33).toString('hex'); + cursor += 33; + + transactions.push({ txid, outputs, scanTweak }); + } + + return { type, transactions }; +}; + +export const btcToSats = (amount: number): number => { + return currency(amount, { precision: 8 }).multiply(SATS_PER_BTC).value; +}; diff --git a/e2e/helpers/docker/docker-compose.yml b/e2e/helpers/docker/docker-compose.yml index 599367b..097c202 100644 --- a/e2e/helpers/docker/docker-compose.yml +++ b/e2e/helpers/docker/docker-compose.yml @@ -8,7 +8,8 @@ services: rest=1 rpcbind=0.0.0.0:18443 rpcallowip=0.0.0.0/0 - rpcauth=polaruser:29fc7c114646a46c59c029eb076a0967$a985383e5b88d84acf241765c558c408b3fed0ab887575568b4e7fb8e77af6e4 + rpcuser=polaruser + rpcpassword=password debug=1 logips=1 logtimemicros=1 diff --git a/e2e/helpers/rpc.helper.ts b/e2e/helpers/rpc.helper.ts new file mode 100644 index 0000000..6f5ffa4 --- /dev/null +++ b/e2e/helpers/rpc.helper.ts @@ -0,0 +1,135 @@ +import { readFileSync } from 'fs'; +import * as yaml from 'js-yaml'; +import axios, { AxiosRequestConfig } from 'axios'; + +type PartialUtxo = { + value: number; + scriptPubKey: { + address: string; + }; +}; + +export class BitcoinRPCUtil { + private readonly url: string; + private readonly axiosConfig: AxiosRequestConfig; + + constructor(configPath = './config/e2e.config.yaml') { + const config = yaml.load(readFileSync(configPath, 'utf8')) as Record< + string, + any + >; + const user = config.bitcoinCore.rpcUser; + const password = config.bitcoinCore.rpcPass; + const host = config.bitcoinCore.rpcHost; + const port = config.bitcoinCore.rpcPort; + const protocol = config.bitcoinCore.protocol; + this.axiosConfig = { + method: 'POST', + auth: { + username: user, + password: password, + }, + url: `${protocol}://${host}:${port}/`, + }; + } + + public async request(config: AxiosRequestConfig): Promise { + try { + const response = await axios.request({ + ...this.axiosConfig, + ...config, + }); + return response.data?.result; + } catch (error) { + if (axios.isAxiosError(error)) { + return { + body: error.response?.data, + status: error.response?.status, + }; + } else { + throw error; + } + } + } + + getBlockHeight(): Promise { + return this.request({ + data: { + method: 'getblockcount', + params: [], + }, + }); + } + + createWallet(walletName: string): Promise { + return this.request({ + data: { + method: 'createwallet', + params: [walletName], + }, + }); + } + + loadWallet(walletName: string): Promise { + return this.request({ + data: { + method: 'loadwallet', + params: [walletName], + }, + }); + } + + getNewAddress(): Promise { + return this.request({ + data: { + method: 'getnewaddress', + params: [], + }, + }); + } + + mineToAddress(numBlocks: number, address: string): Promise { + return this.request({ + data: { + method: 'generatetoaddress', + params: [numBlocks, address], + }, + }); + } + + sendToAddress(address: string, amount: number): Promise { + return this.request({ + data: { + method: 'sendtoaddress', + params: [address, amount], + }, + }); + } + + sendRawTransaction(rawTx: string): Promise { + return this.request({ + data: { + method: 'sendrawtransaction', + params: [rawTx], + }, + }); + } + + getTxOut(txid: string, vout: number): Promise { + return this.request({ + data: { + method: 'gettxout', + params: [txid, vout], + }, + }); + } + + getRawTransaction(txid: string): Promise { + return this.request({ + data: { + method: 'getrawtransaction', + params: [txid], + }, + }); + } +} diff --git a/e2e/helpers/wallet.helper.ts b/e2e/helpers/wallet.helper.ts new file mode 100644 index 0000000..0763f0c --- /dev/null +++ b/e2e/helpers/wallet.helper.ts @@ -0,0 +1,126 @@ +import { mnemonicToSeedSync } from 'bip39'; +import { BIP32Interface, fromSeed } from 'bip32'; +import * as ecc from 'tiny-secp256k1'; +import { + initEccLib, + payments, + Psbt, + networks, + Payment, + Transaction, +} from 'bitcoinjs-lib'; +import { btcToSats } from '@e2e/helpers/common.helper'; + +initEccLib(ecc); + +export type UTXO = { + txid: string; + vout: number; + value: number; + rawTx: string; +}; +export class WalletHelper { + private mnemonic: string; + private seed: Buffer; + private root: BIP32Interface; + + constructor( + mnemonic = 'select approve zebra athlete happy whisper parrot will yellow fortune demand father', + ) { + this.mnemonic = mnemonic; + this.seed = mnemonicToSeedSync(this.mnemonic); + this.root = fromSeed(this.seed, networks.regtest); + } + + generateAddresses(count: number, type: 'p2wpkh' | 'p2tr'): Payment[] { + const outputs: Payment[] = []; + for (let i = 0; i < count; i++) { + const path = `m/84'/0'/0'/0/${i}`; + const child = this.root.derivePath(path); + let output; + + switch (type) { + case 'p2wpkh': + output = payments.p2wpkh({ + pubkey: Buffer.from(child.publicKey), + network: networks.regtest, + }); + break; + case 'p2tr': + output = payments.p2tr({ + internalPubkey: Buffer.from(toXOnly(child.publicKey)), + network: networks.regtest, + }); + break; + default: + throw new Error('Unsupported address type'); + } + + outputs.push(output); + } + return outputs; + } + + /** + * Craft and sign a transaction sending 5.999 BTC to the provided Taproot address. + * + * @param utxos - Array of UTXOs to spend from. + * @param taprootOutput - The Taproot output to send to. + * @returns {Transaction} The raw signed transaction. + */ + craftTransaction(utxos: UTXO[], taprootOutput: Payment): Transaction { + const psbt = new Psbt({ network: networks.regtest }); + + utxos.forEach((utxo) => { + psbt.addInput({ + hash: utxo.txid, + index: utxo.vout, + nonWitnessUtxo: Buffer.from(utxo.rawTx, 'hex'), + }); + }); + + // Add the output to the Taproot address (6 BTC) + const totalInputValue = utxos.reduce( + (acc, utxo) => acc + utxo.value, + 0, + ); + + const outputValue = btcToSats(5.999); + const fee = btcToSats(0.001); + + if (totalInputValue < outputValue + fee) { + throw new Error('Insufficient funds'); + } + + psbt.addOutput({ + address: taprootOutput.address, + tapInternalKey: taprootOutput.internalPubkey, + value: outputValue, + }); + + // Sign the inputs with the corresponding private keys + utxos.forEach((utxo, index) => { + const child = this.root.derivePath(`m/84'/0'/0'/0/${index}`); + const keyPair = child; + psbt.signInput(index, keyPair); + }); + + psbt.finalizeAllInputs(); + + return psbt.extractTransaction(true); + } +} + +const toXOnly = (pubKey) => { + if (pubKey.length === 32) { + return pubKey; + } + + if (pubKey.length === 33 && (pubKey[0] === 0x02 || pubKey[0] === 0x03)) { + return pubKey.slice(1, 33); + } + + throw new Error( + 'Invalid public key format. Expected compressed public key or x-only key.', + ); +}; diff --git a/e2e/indexer.e2e-spec.ts b/e2e/indexer.e2e-spec.ts new file mode 100644 index 0000000..e8b2043 --- /dev/null +++ b/e2e/indexer.e2e-spec.ts @@ -0,0 +1,107 @@ +import { WalletHelper, UTXO } from '@e2e/helpers/wallet.helper'; +import { BitcoinRPCUtil } from '@e2e/helpers/rpc.helper'; +import { ApiHelper } from '@e2e/helpers/api.helper'; +import { parseSilentBlock } from '@e2e/helpers/common.helper'; +import { Payment } from 'bitcoinjs-lib'; +import { generateScanTweak } from '@e2e/helpers/common.helper'; +import { btcToSats } from '@e2e/helpers/common.helper'; +import { IndexerService } from '@/indexer/indexer.service'; + +describe('Silent Pay Indexer Tests', () => { + let walletHelper: WalletHelper; + let bitcoinRPCUtil: BitcoinRPCUtil; + let apiHelper: ApiHelper; + let indexerService: IndexerService; + let initialAddress: string; + let p2wkhOutputs: Payment[]; + let taprootOutput: Payment; + let utxos: UTXO[]; + let transactions: any[]; + let transaction: any; + + beforeAll(async () => { + walletHelper = new WalletHelper(); + bitcoinRPCUtil = new BitcoinRPCUtil(); + apiHelper = new ApiHelper(); + indexerService = new IndexerService({} as any); + + await bitcoinRPCUtil.createWallet('test_wallet'); + initialAddress = await bitcoinRPCUtil.getNewAddress(); + taprootOutput = walletHelper.generateAddresses(1, 'p2tr')[0]; + p2wkhOutputs = walletHelper.generateAddresses(6, 'p2wpkh'); + await bitcoinRPCUtil.mineToAddress(101, initialAddress); + + const txidList = []; + for (const output of p2wkhOutputs) { + const txid = await bitcoinRPCUtil.sendToAddress(output.address, 1); + txidList.push(txid); + } + await bitcoinRPCUtil.mineToAddress(6, initialAddress); + + utxos = []; + for (let i = 0; i < 6; i++) { + for (let vout = 0; vout < 2; vout++) { + const utxo = await bitcoinRPCUtil.getTxOut(txidList[i], vout); + + if ( + utxo && + utxo.scriptPubKey && + utxo.scriptPubKey.address === p2wkhOutputs[i].address + ) { + utxos.push({ + txid: txidList[i], + vout: vout, + value: btcToSats(utxo.value), + rawTx: await bitcoinRPCUtil.getRawTransaction( + txidList[i], + ), + }); + break; + } + } + } + transaction = walletHelper.craftTransaction( + utxos.slice(0, 6), + taprootOutput, // Send 5.99 BTC to taproot address with .01 BTC fee + ); + + await bitcoinRPCUtil.sendRawTransaction(transaction.toHex()); + const blockHash = ( + await bitcoinRPCUtil.mineToAddress(1, initialAddress) + )[0]; + + await new Promise((resolve) => setTimeout(resolve, 15000)); + const response = await apiHelper.get( + `/silent-block/hash/${blockHash}`, + { + responseType: 'arraybuffer', + }, + ); + + const decodedBlock = parseSilentBlock(response.data); + transactions = decodedBlock.transactions; + }); + + it('should ensure that the correct silent block is fetched', async () => { + expect(transactions).toHaveLength(1); + const foundTx = transactions[0]; + + expect(foundTx.outputs.length).toBe(1); + const output = foundTx.outputs[0]; + expect(output).toBeDefined(); + expect(output.value).toEqual(btcToSats(5.999)); + + const taprootPubKeyBuffer = Buffer.from(taprootOutput.pubkey).toString( + 'hex', + ); + + expect(output.pubkey).toEqual(taprootPubKeyBuffer); + + const scantweak = generateScanTweak( + transaction, + p2wkhOutputs, + indexerService, + ); + expect(foundTx.scanTweak).toEqual(scantweak); + }); +}); diff --git a/package-lock.json b/package-lock.json index 47adfb3..6ce3067 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,9 @@ "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "bip32": "^2.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.6-rc.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "eslint": "^8.0.1", @@ -54,6 +57,7 @@ "run-script-webpack-plugin": "^0.2.0", "source-map-support": "^0.5.20", "supertest": "^6.1.3", + "tiny-secp256k1": "^2.2.3", "ts-jest": "29.0.5", "ts-loader": "^9.2.3", "ts-node": "^10.0.0", @@ -2173,6 +2177,18 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, + "node_modules/@noble/hashes": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.5.0.tgz", + "integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==", + "dev": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -3545,6 +3561,15 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base-x": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.10.tgz", + "integrity": "sha512-7d0s06rR9rYaIWHkpfLIFICM/tkSVdoPC9qYAQRpxn9DdKNWNsKC0uk++akckyLq16Tx2WIinnZ6WRriAt6njQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3572,6 +3597,12 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bech32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz", + "integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==", + "dev": true + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3592,6 +3623,107 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bip174": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bip32": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-2.0.0.tgz", + "integrity": "sha512-dYuSVcH43KXLsmiOlud62m5ZP3KG8UBUAbP+wMq8VKuhPvxOOM8VOXRs6ppcJB4iUJtYjmg7VuzTlr8waMusfg==", + "dev": true, + "dependencies": { + "@types/node": "10.12.18", + "bs58check": "^2.1.1", + "create-hash": "^1.2.0", + "create-hmac": "^1.1.7", + "tiny-secp256k1": "^1.0.0", + "typeforce": "^1.11.5", + "wif": "^2.0.6" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bip32/node_modules/@types/node": { + "version": "10.12.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.12.18.tgz", + "integrity": "sha512-fh+pAqt4xRzPfqA6eh3Z2y6fyZavRIumvjhaCL753+TVkGKGhpPeyrJG2JftD0T9q4GF00KjefsQ+PQNDdWQaQ==", + "dev": true + }, + "node_modules/bip32/node_modules/tiny-secp256k1": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-1.1.6.tgz", + "integrity": "sha512-FmqJZGduTyvsr2cF3375fqGHUovSwDi/QytexX1Se4BPuPZpTE5Ftp5fg+EFSuEf3lhZqgCRjEG3ydUQ/aNiwA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.3.0", + "bn.js": "^4.11.8", + "create-hmac": "^1.1.7", + "elliptic": "^6.4.0", + "nan": "^2.13.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.6.tgz", + "integrity": "sha512-Fk8+Vc+e2rMoDU5gXkW9tD+313rhkm5h6N9HfZxXvYU9LedttVvmXKTgd9k5rsQJjkSfsv6XRM8uhJv94SrvcA==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^2.1.1", + "bs58check": "^3.0.1", + "typeforce": "^1.11.3", + "varuint-bitcoin": "^1.1.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/base-x": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.0.tgz", + "integrity": "sha512-FuwxlW4H5kh37X/oW59pwTzzTKRzfrrQwhmyspRM7swOEZcHtDZSCt45U6oKgtuFE+WYPblePMVIPR4RZrh/hw==", + "dev": true + }, + "node_modules/bitcoinjs-lib/node_modules/bs58": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", + "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", + "dev": true, + "dependencies": { + "base-x": "^4.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/bs58check": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-3.0.1.tgz", + "integrity": "sha512-hjuuJvoWEybo7Hn/0xOrczQKKEKD63WguEjlhLExYs2wUBcebDC1jDNK17eEAD2lYfw82d5ASC1d7K3SWszjaQ==", + "dev": true, + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^5.0.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3727,6 +3859,26 @@ "node": ">= 6" } }, + "node_modules/bs58": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", + "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", + "dev": true, + "dependencies": { + "base-x": "^3.0.2" + } + }, + "node_modules/bs58check": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-2.1.2.tgz", + "integrity": "sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==", + "dev": true, + "dependencies": { + "bs58": "^4.0.0", + "create-hash": "^1.1.0", + "safe-buffer": "^5.1.2" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -4012,6 +4164,16 @@ "node": ">=8" } }, + "node_modules/cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "dependencies": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/cjs-module-lexer": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", @@ -4445,6 +4607,33 @@ } } }, + "node_modules/create-hash": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "node_modules/create-hmac": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "dependencies": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -6404,6 +6593,34 @@ "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", "optional": true }, + "node_modules/hash-base": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz", + "integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "^3.6.0", + "safe-buffer": "^5.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/hash-base/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/hash.js": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", @@ -8565,6 +8782,17 @@ "tmpl": "1.0.5" } }, + "node_modules/md5.js": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz", + "integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==", + "dev": true, + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -8966,6 +9194,12 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nan": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.20.0.tgz", + "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==", + "dev": true + }, "node_modules/napi-build-utils": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", @@ -10264,6 +10498,16 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "dev": true }, + "node_modules/ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "dependencies": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -11424,6 +11668,27 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "dev": true }, + "node_modules/tiny-secp256k1": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/tiny-secp256k1/-/tiny-secp256k1-2.2.3.tgz", + "integrity": "sha512-SGcL07SxcPN2nGKHTCvRMkQLYPSoeFcvArUSCYtjVARiFAWU44cCIqYS0mYAU6nY7XfvwURuTIGo2Omt3ZQr0Q==", + "dev": true, + "dependencies": { + "uint8array-tools": "0.0.7" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tiny-secp256k1/node_modules/uint8array-tools": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", + "integrity": "sha512-vrrNZJiusLWoFWBqz5Y5KMCgP9W9hnjZHzZiZRT8oNAkq3d5Z5Oe76jAvVVSRh4U8GGR90N2X1dWtrhvx6L8UQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -11788,6 +12053,12 @@ "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, + "node_modules/typeforce": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz", + "integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g==", + "dev": true + }, "node_modules/typeorm": { "version": "0.3.20", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.20.tgz", @@ -12141,6 +12412,15 @@ "node": ">= 0.10" } }, + "node_modules/varuint-bitcoin": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-1.1.2.tgz", + "integrity": "sha512-4EVb+w4rx+YfVM32HQX42AbbT7/1f5zwAYhIujKXKk8NQK+JfRVl3pqT3hjNn/L+RstigmGGKVwHA/P0wgITZw==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.1" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12316,6 +12596,15 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/wif": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz", + "integrity": "sha512-HIanZn1zmduSF+BQhkE+YXIbEiH0xPr1012QbFEGB0xsKqJii0/SqJjyn8dFv6y36kOznMgMB+LGcbZTJ1xACQ==", + "dev": true, + "dependencies": { + "bs58check": "<3.0.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 0af3aa6..568d1ca 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,9 @@ "@types/supertest": "^2.0.11", "@typescript-eslint/eslint-plugin": "^5.0.0", "@typescript-eslint/parser": "^5.0.0", + "bip32": "^2.0.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^6.1.6-rc.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "eslint": "^8.0.1", @@ -70,6 +73,7 @@ "run-script-webpack-plugin": "^0.2.0", "source-map-support": "^0.5.20", "supertest": "^6.1.3", + "tiny-secp256k1": "^2.2.3", "ts-jest": "29.0.5", "ts-loader": "^9.2.3", "ts-node": "^10.0.0", diff --git a/src/indexer/indexer.service.ts b/src/indexer/indexer.service.ts index e5924bf..57bcef7 100644 --- a/src/indexer/indexer.service.ts +++ b/src/indexer/indexer.service.ts @@ -31,10 +31,29 @@ export class IndexerService { blockHeight: number, blockHash: string, ) { + const scanResult = this.computeScanTweak(txid, vin, vout); + if (scanResult !== null) { + const [scanTweak, eligibleOutputPubKeys] = scanResult; + const transaction = new Transaction(); + transaction.id = txid; + transaction.blockHeight = blockHeight; + transaction.blockHash = blockHash; + transaction.scanTweak = scanTweak.toString('hex'); + transaction.outputs = eligibleOutputPubKeys; + transaction.isSpent = false; + + await this.transactionsService.saveTransaction(transaction); + } + } + + public computeScanTweak( + txid: string, + vin: TransactionInput[], + vout: TransactionOutput[], + ): [Buffer, TransactionOutputEntity[]] | null { const eligibleOutputPubKeys: TransactionOutputEntity[] = []; // verify if the transaction contains at least one BIP341 P2TR output - // this output could be a potential silent payment let n = 0; for (const output of vout) { if (this.isP2TR(output.scriptPubKey)) { @@ -47,20 +66,14 @@ export class IndexerService { n++; } - if (eligibleOutputPubKeys.length === 0) return; + if (eligibleOutputPubKeys.length === 0) return null; // verify that the transaction does not spend an output with SegWit version > 1 - // this would make the transaction ineligible for silent payment v0 for (const input of vin) { - // grab the first op code of the prevOutScript const firstOpCode = parseInt(input.prevOutScript.slice(0, 2), 16); - - // if the first op code is in the range OP_2-OP_16 (0x52-0x60) - // then the transaction is ineligible - if (0x52 <= firstOpCode && firstOpCode <= 0x60) return; + if (0x52 <= firstOpCode && firstOpCode <= 0x60) return null; } - // extract the input public keys from the transaction const pubKeys: Buffer[] = []; for (const input of vin) { const pubKey = extractPubKeyFromScript( @@ -71,7 +84,7 @@ export class IndexerService { if (pubKey) pubKeys.push(pubKey); } - if (pubKeys.length === 0) return; + if (pubKeys.length === 0) return null; const smallestOutpoint = this.getSmallestOutpoint(vin); const sumOfPublicKeys = Buffer.from(publicKeyCombine(pubKeys, true)); @@ -81,24 +94,15 @@ export class IndexerService { Buffer.concat([smallestOutpoint, sumOfPublicKeys]), ); - // A * inputHash const scanTweak = Buffer.from( publicKeyTweakMul(sumOfPublicKeys, inputHash, true), ); - const transaction = new Transaction(); - transaction.id = txid; - transaction.blockHeight = blockHeight; - transaction.blockHash = blockHash; - transaction.scanTweak = scanTweak.toString('hex'); - transaction.outputs = eligibleOutputPubKeys; - transaction.isSpent = false; - - await this.transactionsService.saveTransaction(transaction); + return [scanTweak, eligibleOutputPubKeys]; } private isP2TR(spk: string): boolean { - if (spk.match(/^5120[0-9a-fA-F]{64}$/)) return true; + return !!spk.match(/^5120[0-9a-fA-F]{64}$/); } private getSmallestOutpoint(vins: TransactionInput[]): Buffer {