diff --git a/config/e2e.config.yaml b/config/e2e.config.yaml index c76b343..fb425f0 100644 --- a/config/e2e.config.yaml +++ b/config/e2e.config.yaml @@ -10,7 +10,7 @@ app: providerType: BITCOIN_CORE_RPC bitcoinCore: protocol: http - rpcHost: localhost - rpcPass: polarpass + rpcHost: 127.0.0.1 + rpcPass: password rpcUser: polaruser rpcPort: 18443 diff --git a/e2e/helpers/api.helper.ts b/e2e/helpers/api.helper.ts index 3356cf3..89aeeee 100644 --- a/e2e/helpers/api.helper.ts +++ b/e2e/helpers/api.helper.ts @@ -17,8 +17,8 @@ export class ApiHelper { 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..537781d --- /dev/null +++ b/e2e/helpers/common.helper.ts @@ -0,0 +1,92 @@ +import { Payment, Transaction } from 'bitcoinjs-lib'; +import { computeScantweak } from '@/indexer/indexer.service'; + +export function generateScantweak( + transaction: Transaction, + outputs: Payment[], +): string { + const txid = transaction.getId(); + + const txin = transaction.ins.map((input, index) => { + const isWitness = transaction.hasWitnesses(); + + return { + txid: Buffer.from(input.hash).reverse().toString('hex'), + vout: input.index, + scriptSig: isWitness + ? '' + : Buffer.from(input.script).toString('hex'), + witness: isWitness + ? input.witness.map((v) => Buffer.from(v).toString('hex')) + : undefined, + prevOutScript: Buffer.from(outputs[index].output).toString('hex'), + }; + }); + + const txout = transaction.outs.map((output) => ({ + scriptPubKey: Buffer.from(output.script).toString('hex'), + value: Number(output.value), + })); + + const scantweak = computeScantweak(txid, txin, txout)[0]; + + return scantweak.toString('hex'); +} + +export const varIntSize = (value: number): number => { + if (value < 0xfd) return 1; + else if (value <= 0xffff) return 3; + else if (value <= 0xffffffff) return 5; + else return 9; +}; + +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 const parseSilentBlock = (data: Buffer) => { + const type = data.readUInt8(0); + const transactions = []; + + 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 }; +}; 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..c27ca25 --- /dev/null +++ b/e2e/helpers/rpc.helper.ts @@ -0,0 +1,139 @@ +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.url = `${protocol}://${host}:${port}/`; + this.axiosConfig = { + method: 'POST', + auth: { + username: user, + password: password, + }, + }; + } + + public async request(config: AxiosRequestConfig): Promise { + try { + const response = await axios.request({ + ...this.axiosConfig, + ...config, + url: this.url, + }); + return response.data?.result; + } catch (error) { + throw new Error( + `Request failed with status code ${error.response.status}`, + ); + } + } + + async getBlockHeight(): Promise { + return await this.request({ + data: { + jsonrpc: '1.0', + id: 'silent_payment_indexer', + method: 'getblockcount', + params: [], + }, + }); + } + + async createWallet(walletName: string): Promise { + return await this.request({ + data: { + method: 'createwallet', + params: [walletName], + jsonrpc: '1.0', + id: 'silent_payment_indexer', + }, + }); + } + + async loadWallet(walletName: string): Promise { + return await this.request({ + data: { + method: 'loadwallet', + params: [walletName], + jsonrpc: '1.0', + id: 'silent_payment_indexer', + }, + }); + } + + async getNewAddress(): Promise { + return await this.request({ + data: { + method: 'getnewaddress', + params: [], + jsonrpc: '1.0', + id: 'silent_payment_indexer', + }, + }); + } + + async mineToAddress(numBlocks: number, address: string): Promise { + return await this.request({ + data: { + method: 'generatetoaddress', + params: [numBlocks, address], + }, + }); + } + + async sendToAddress(address: string, amount: number): Promise { + return await this.request({ + data: { + method: 'sendtoaddress', + params: [address, amount], + }, + }); + } + + async sendRawTransaction(rawTx: string): Promise { + return await this.request({ + data: { + method: 'sendrawtransaction', + params: [rawTx], + }, + }); + } + + async getTxOut(txid: string, vout: number): Promise { + return await this.request({ + data: { + method: 'gettxout', + params: [txid, vout], + }, + }); + } + + async getRawTransaction(txid: string): Promise { + return await 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..2448711 --- /dev/null +++ b/e2e/helpers/wallet.helper.ts @@ -0,0 +1,131 @@ +import { mnemonicToSeedSync } from 'bip39'; +import { BIP32Factory } from 'bip32'; +import * as ecc from 'tiny-secp256k1'; +import { + initEccLib, + payments, + Psbt, + networks, + Payment, + Transaction, +} from 'bitcoinjs-lib'; + +initEccLib(ecc); +const bip32 = BIP32Factory(ecc); + +export class WalletHelper { + private mnemonic: string; + private seed: Buffer; + private root: any; + + constructor( + mnemonic = 'select approve zebra athlete happy whisper parrot will yellow fortune demand father', + ) { + this.mnemonic = mnemonic; + this.seed = mnemonicToSeedSync(this.mnemonic); + this.root = bip32.fromSeed(this.seed, networks.regtest); + } + + getMnemonic(): string { + return this.mnemonic; + } + + generateAddresses( + count: number, + type: 'p2wpkh' | 'p2wsh' | '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: child.publicKey, + network: networks.regtest, + }); + break; + case 'p2tr': + output = payments.p2tr({ + internalPubkey: toXOnly(child.publicKey), + network: networks.regtest, + }); + break; + default: + throw new Error('Unsupported address type'); + } + + outputs.push(output); + } + return outputs; + } + + createWallet(): { mnemonic: string; addresses: Payment[] } { + const addresses = this.generateAddresses(10, 'p2wpkh'); + return { mnemonic: this.mnemonic, addresses }; + } + + /** + * Craft and sign a transaction sending 6 BTC to the provided Taproot address. + * + * @param utxos - Array of UTXOs to spend from. + * @param taprootAddress - The Taproot address to send to. + * @param fee - The fee to apply in satoshis. + * @returns {string} The raw signed transaction hex. + */ + craftTransaction( + utxos: Array<{ + txid: string; + vout: number; + value: number; + rawTx: string; + }>, + 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 BTC_TO_SATOSHI = 1e8; + const outputValue = 5.999 * BTC_TO_SATOSHI; + const fee = 0.001 * BTC_TO_SATOSHI; + + if (totalInputValue < outputValue + fee) { + throw new Error('Insufficient funds'); + } + + psbt.addOutput({ + address: taprootOutput.address, + tapInternalKey: taprootOutput.internalPubkey, + value: BigInt(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) => + pubKey.length === 32 ? pubKey : pubKey.slice(1, 33); diff --git a/e2e/indexer.e2e-spec.ts b/e2e/indexer.e2e-spec.ts new file mode 100644 index 0000000..0ed237c --- /dev/null +++ b/e2e/indexer.e2e-spec.ts @@ -0,0 +1,128 @@ +import { WalletHelper } from '@e2e/helpers/wallet.helper'; +import { BitcoinRPCUtil } from '@e2e/helpers/rpc.helper'; +import { ApiHelper } from '@e2e/helpers/api.helper'; +import { parseSilentBlock } from '@/common/common'; +import { Payment, Transaction } from 'bitcoinjs-lib'; +import { computeScantweak } from '@/indexer/indexer.service'; + +function generateScantweak( + transaction: Transaction, + outputs: Payment[], +): string { + const txid = transaction.getId(); + + const txin = transaction.ins.map((input, index) => { + const isWitness = transaction.hasWitnesses(); + + return { + txid: Buffer.from(input.hash).reverse().toString('hex'), + vout: input.index, + scriptSig: isWitness + ? '' + : Buffer.from(input.script).toString('hex'), + witness: isWitness + ? input.witness.map((v) => Buffer.from(v).toString('hex')) + : undefined, + prevOutScript: Buffer.from(outputs[index].output).toString('hex'), + }; + }); + + const txout = transaction.outs.map((output) => ({ + scriptPubKey: Buffer.from(output.script).toString('hex'), + value: Number(output.value), + })); + + const scantweak = computeScantweak(txid, txin, txout)[0]; + + return scantweak.toString('hex'); +} + +describe('WalletHelper Integration Tests', () => { + let walletHelper: WalletHelper; + let bitcoinRPCUtil: BitcoinRPCUtil; + let apiHelper: ApiHelper; + let initialAddress: string; + let p2wkhOutputs: Payment[]; + let taprootOutput: Payment; + let utxos: { txid: string; vout: number; value: number; rawTx: string }[]; + + beforeAll(async () => { + walletHelper = new WalletHelper(); + bitcoinRPCUtil = new BitcoinRPCUtil(); + apiHelper = new ApiHelper(); + + await bitcoinRPCUtil.createWallet('test_wallet1'); + 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.address === p2wkhOutputs[i].address + ) { + utxos.push({ + txid: txidList[i], + vout: vout, + value: Math.round(utxo.value * 1e8), + rawTx: await bitcoinRPCUtil.getRawTransaction( + txidList[i], + ), + }); + break; + } + } + } + }); + + it('should ensure that the correct silent block is fetched', async () => { + const transaction = walletHelper.craftTransaction( + utxos.slice(0, 6), + taprootOutput, // Send 5 BTC to taproot address with 1 BTC fee + ); + + await bitcoinRPCUtil.sendRawTransaction(transaction.toHex()); + const blockHash = ( + await bitcoinRPCUtil.mineToAddress(1, initialAddress) + )[0]; + + await new Promise((resolve) => setTimeout(resolve, 30000)); + const response = await apiHelper.get( + `/silent-block/hash/${blockHash}`, + { + responseType: 'arraybuffer', + }, + ); + + const decodedBlock = parseSilentBlock(response.data); + const transactions = decodedBlock.transactions; + 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(5.999 * 1e8); + + const uint8Array = new Uint8Array(taprootOutput.pubkey); + const buffer = Buffer.from(uint8Array); + const hexString = buffer.toString('hex'); + + expect(output.pubkey).toEqual(hexString); + + const scantweak = generateScantweak(transaction, p2wkhOutputs); + expect(foundTx.scanTweak).toEqual(scantweak); + }); +}); diff --git a/package-lock.json b/package-lock.json index 47adfb3..67abc31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,16 @@ "@nestjs/typeorm": "^10.0.2", "@nestjs/websockets": "^10.3.7", "axios": "^1.7.2", + "bip32": "^5.0.0-rc.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^7.0.0-rc.0", "currency.js": "^2.0.4", "js-yaml": "^4.1.0", "reflect-metadata": "^0.1.13", "rxjs": "^7.2.0", "secp256k1": "^5.0.0", "sqlite3": "^5.1.7", + "tiny-secp256k1": "^2.2.3", "typeorm": "^0.3.20" }, "devDependencies": { @@ -2173,6 +2177,17 @@ "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==", + "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", @@ -2286,6 +2301,14 @@ "node": ">=14" } }, + "node_modules/@scure/base": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", + "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3545,6 +3568,11 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "node_modules/base-x": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.0.tgz", + "integrity": "sha512-sMW3VGSX1QWVFA6l8U62MLKz29rRfpTlYdCqLdpLo1/Yd4zZwSbnUaDfciIAowAqvq7YFnWq9hrhdg1KYgc1lQ==" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3572,6 +3600,11 @@ "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==" + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -3592,6 +3625,128 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bip174": { + "version": "3.0.0-rc.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0-rc.1.tgz", + "integrity": "sha512-+8P3BpSairVNF2Nee6Ksdc1etIjWjBOi/MH0MwKtq9YaYp+S2Hk2uvup0e8hCT4IKlS58nXJyyQVmW92zPoD4Q==", + "dependencies": { + "uint8array-tools": "^0.0.9", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bip174/node_modules/uint8array-tools": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz", + "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/bip32": { + "version": "5.0.0-rc.0", + "resolved": "https://registry.npmjs.org/bip32/-/bip32-5.0.0-rc.0.tgz", + "integrity": "sha512-5hVFGrdCnF8GB1Lj2eEo4PRE7+jp+3xBLnfNjydivOkMvKmUKeJ9GG8uOy8prmWl3Oh154uzgfudR1FRkNBudA==", + "dependencies": { + "@noble/hashes": "^1.2.0", + "@scure/base": "^1.1.1", + "uint8array-tools": "^0.0.8", + "valibot": "^0.37.0", + "wif": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bip32/node_modules/typescript": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/bip32/node_modules/valibot": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.37.0.tgz", + "integrity": "sha512-FQz52I8RXgFgOHym3XHYSREbNtkgSjF9prvMFH1nBsRyfL6SfCzoT1GuSDTlbsuPubM7/6Kbw0ZMQb8A+V+VsQ==", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, + "node_modules/bitcoinjs-lib": { + "version": "7.0.0-rc.0", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.0-rc.0.tgz", + "integrity": "sha512-7CQgOIbREemKR/NT2uc3uO/fkEy+6CM0sLxboVVY6bv6DbZmPt3gg5Y/hhWgQFeZu5lfTbtVAv32MIxf7lMh4g==", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bech32": "^2.0.0", + "bip174": "^3.0.0-rc.0", + "bs58check": "^4.0.0", + "uint8array-tools": "^0.0.9", + "valibot": "^0.38.0", + "varuint-bitcoin": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/typescript": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz", + "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", + "optional": true, + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/bitcoinjs-lib/node_modules/uint8array-tools": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz", + "integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/bitcoinjs-lib/node_modules/valibot": { + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-0.38.0.tgz", + "integrity": "sha512-RCJa0fetnzp+h+KN9BdgYOgtsMAG9bfoJ9JSjIhFHobKWVWyzM3jjaeNTdpFK9tQtf3q1sguXeERJ/LcmdFE7w==", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3727,6 +3882,23 @@ "node": ">= 6" } }, + "node_modules/bs58": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz", + "integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==", + "dependencies": { + "base-x": "^5.0.0" + } + }, + "node_modules/bs58check": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz", + "integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==", + "dependencies": { + "@noble/hashes": "^1.2.0", + "bs58": "^6.0.0" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -11424,6 +11596,25 @@ "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==", + "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==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -12002,6 +12193,14 @@ "node": ">=8" } }, + "node_modules/uint8array-tools": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz", + "integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -12141,6 +12340,14 @@ "node": ">= 0.10" } }, + "node_modules/varuint-bitcoin": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz", + "integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==", + "dependencies": { + "uint8array-tools": "^0.0.8" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -12316,6 +12523,14 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/wif": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz", + "integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==", + "dependencies": { + "bs58check": "^4.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..23451dd 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": "^5.0.0-rc.0", + "bip39": "^3.1.0", + "bitcoinjs-lib": "^7.0.0-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/common/common.ts b/src/common/common.ts index ac8a415..1eb18bd 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -147,3 +147,54 @@ export const varIntSize = (value: number): number => { else if (value <= 0xffffffff) return 5; else return 9; }; + +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 const parseSilentBlock = (data: Buffer) => { + const type = data.readUInt8(0); + const transactions = []; + + 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 }; +}; diff --git a/src/indexer/indexer.service.ts b/src/indexer/indexer.service.ts index e5924bf..b01c558 100644 --- a/src/indexer/indexer.service.ts +++ b/src/indexer/indexer.service.ts @@ -20,98 +20,110 @@ export type TransactionOutput = { value: number; }; -@Injectable() -export class IndexerService { - constructor(private readonly transactionsService: TransactionsService) {} - - async index( - txid: string, - vin: TransactionInput[], - vout: TransactionOutput[], - blockHeight: number, - blockHash: string, - ) { - 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)) { - eligibleOutputPubKeys.push({ - pubKey: output.scriptPubKey.substring(4), - value: output.value, - vout: n, - }); - } - n++; +export function computeScantweak( + txid: string, + vin: TransactionInput[], + vout: TransactionOutput[], +): [Buffer, TransactionOutputEntity[]] | undefined { + 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 (isP2TR(output.scriptPubKey)) { + eligibleOutputPubKeys.push({ + pubKey: output.scriptPubKey.substring(4), + value: output.value, + vout: n, + }); } + n++; + } - if (eligibleOutputPubKeys.length === 0) return; + if (eligibleOutputPubKeys.length === 0) return undefined; - // 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); + // 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 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 undefined; + } - // extract the input public keys from the transaction - const pubKeys: Buffer[] = []; - for (const input of vin) { - const pubKey = extractPubKeyFromScript( - Buffer.from(input.prevOutScript, 'hex'), - Buffer.from(input.scriptSig, 'hex'), - input.witness?.map((w) => Buffer.from(w, 'hex')), - ); - if (pubKey) pubKeys.push(pubKey); - } + // extract the input public keys from the transaction + const pubKeys: Buffer[] = []; + for (const input of vin) { + const pubKey = extractPubKeyFromScript( + Buffer.from(input.prevOutScript, 'hex'), + Buffer.from(input.scriptSig, 'hex'), + input.witness?.map((w) => Buffer.from(w, 'hex')), + ); + if (pubKey) pubKeys.push(pubKey); + } - if (pubKeys.length === 0) return; + if (pubKeys.length === 0) return undefined; - const smallestOutpoint = this.getSmallestOutpoint(vin); - const sumOfPublicKeys = Buffer.from(publicKeyCombine(pubKeys, true)); + const smallestOutpoint = getSmallestOutpoint(vin); + const sumOfPublicKeys = Buffer.from(publicKeyCombine(pubKeys, true)); - const inputHash = createTaggedHash( - 'BIP0352/Inputs', - Buffer.concat([smallestOutpoint, sumOfPublicKeys]), - ); + const inputHash = createTaggedHash( + 'BIP0352/Inputs', + Buffer.concat([smallestOutpoint, sumOfPublicKeys]), + ); - // A * inputHash - const scanTweak = Buffer.from( - publicKeyTweakMul(sumOfPublicKeys, inputHash, true), - ); + // 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; + return [scanTweak, eligibleOutputPubKeys]; +} - await this.transactionsService.saveTransaction(transaction); - } +function isP2TR(spk: string): boolean { + if (spk.match(/^5120[0-9a-fA-F]{64}$/)) return true; +} - private isP2TR(spk: string): boolean { - if (spk.match(/^5120[0-9a-fA-F]{64}$/)) return true; +function getSmallestOutpoint(vins: TransactionInput[]): Buffer { + const outpoints = vins.map((vin) => { + const n = Buffer.alloc(4); + n.writeUInt32LE(vin.vout); + return Buffer.concat([Buffer.from(vin.txid, 'hex').reverse(), n]); + }); + + let smallest = outpoints[0]; + for (const outpoint of outpoints) { + if (outpoint.compare(smallest) < 0) smallest = outpoint; } + return smallest; +} - private getSmallestOutpoint(vins: TransactionInput[]): Buffer { - const outpoints = vins.map((vin) => { - const n = Buffer.alloc(4); - n.writeUInt32LE(vin.vout); - return Buffer.concat([Buffer.from(vin.txid, 'hex').reverse(), n]); - }); +@Injectable() +export class IndexerService { + constructor(private readonly transactionsService: TransactionsService) {} - let smallest = outpoints[0]; - for (const outpoint of outpoints) { - if (outpoint.compare(smallest) < 0) smallest = outpoint; + async index( + txid: string, + vin: TransactionInput[], + vout: TransactionOutput[], + blockHeight: number, + blockHash: string, + ) { + const scanResult = computeScantweak(txid, vin, vout); + if (scanResult) { + 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); } - return smallest; } } diff --git a/src/silent-blocks/silent-blocks.service.ts b/src/silent-blocks/silent-blocks.service.ts index 1da1d7c..89dfbfe 100644 --- a/src/silent-blocks/silent-blocks.service.ts +++ b/src/silent-blocks/silent-blocks.service.ts @@ -20,7 +20,10 @@ export class SilentBlocksService { } private encodeSilentBlock(transactions: Transaction[]): Buffer { - const block = Buffer.alloc(this.getSilentBlockLength(transactions)); + const block = Buffer.alloc( + this.getSilentBlockLength(transactions), + 'utf8', + ); let cursor = 0; cursor = block.writeUInt8(SILENT_PAYMENT_BLOCK_TYPE, cursor);