diff --git a/backend/src/core/bitcoin-api/bitcoin-api.interface.ts b/backend/src/core/bitcoin-api/bitcoin-api.interface.ts index 2e1d7e20..ba28c962 100644 --- a/backend/src/core/bitcoin-api/bitcoin-api.interface.ts +++ b/backend/src/core/bitcoin-api/bitcoin-api.interface.ts @@ -1,7 +1,8 @@ -import { Block, RecommendedFees, Transaction, UTXO } from './bitcoin-api.schema'; +import { Address, Block, RecommendedFees, Transaction, UTXO } from './bitcoin-api.schema'; export interface IBitcoinDataProvider { getFeesRecommended(): Promise; + getAddress({ address }: { address: string }): Promise
; getAddressTxsUtxo({ address }: { address: string }): Promise; getAddressTxs({ address, diff --git a/backend/src/core/bitcoin-api/bitcoin-api.schema.ts b/backend/src/core/bitcoin-api/bitcoin-api.schema.ts index f3294c5b..8854982d 100644 --- a/backend/src/core/bitcoin-api/bitcoin-api.schema.ts +++ b/backend/src/core/bitcoin-api/bitcoin-api.schema.ts @@ -23,6 +23,13 @@ export const Block = z.object({ nonce: z.number(), bits: z.number(), difficulty: z.number(), + extras: z.object({ + totalFees: z.number(), + avgFee: z.number(), + avgFeeRate: z.number(), + feeRange: z.number().array(), + coinbaseAddress: z.string(), + }).optional(), }); export type Block = z.infer; @@ -34,6 +41,25 @@ export const Status = z.object({ }); export type Status = z.infer; +export const Address = z.object({ + address: z.string(), + chain_stats: z.object({ + funded_txo_count: z.number(), + funded_txo_sum: z.number(), + spent_txo_count: z.number(), + spent_txo_sum: z.number(), + tx_count: z.number(), + }), + mempool_stats: z.object({ + funded_txo_count: z.number(), + funded_txo_sum: z.number(), + spent_txo_count: z.number(), + spent_txo_sum: z.number(), + tx_count: z.number(), + }), +}); +export type Address = z.infer; + export const Balance = z.object({ address: z.string(), satoshi: z.number(), diff --git a/backend/src/core/bitcoin-api/bitcoin-api.service.ts b/backend/src/core/bitcoin-api/bitcoin-api.service.ts index 23631692..c28bf84e 100644 --- a/backend/src/core/bitcoin-api/bitcoin-api.service.ts +++ b/backend/src/core/bitcoin-api/bitcoin-api.service.ts @@ -170,6 +170,10 @@ export class BitcoinApiService { return this.call('getFeesRecommended'); } + public async getAddress({ address }: { address: string }) { + return this.call('getAddress', { address }); + } + public async getAddressTxsUtxo({ address }: { address: string }) { return this.call('getAddressTxsUtxo', { address }); } diff --git a/backend/src/core/bitcoin-api/provider/electrs.service.ts b/backend/src/core/bitcoin-api/provider/electrs.service.ts index 183f1f70..349a680e 100644 --- a/backend/src/core/bitcoin-api/provider/electrs.service.ts +++ b/backend/src/core/bitcoin-api/provider/electrs.service.ts @@ -1,5 +1,5 @@ import axios, { AxiosInstance } from 'axios'; -import { Block, RecommendedFees, Transaction, UTXO } from '../bitcoin-api.schema'; +import { Address, Block, RecommendedFees, Transaction, UTXO } from '../bitcoin-api.schema'; import { IBitcoinDataProvider } from '../bitcoin-api.interface'; export class ElectrsService implements IBitcoinDataProvider { @@ -15,6 +15,11 @@ export class ElectrsService implements IBitcoinDataProvider { throw new Error('Electrs: Recommended fees not available'); } + public async getAddress({ address }: { address: string }) { + const response = await this.request.get
(`/address/${address}`); + return response.data; + } + public async getAddressTxsUtxo({ address }: { address: string }) { const response = await this.request.get(`/address/${address}/utxo`); return response.data; diff --git a/backend/src/core/bitcoin-api/provider/mempool.service.ts b/backend/src/core/bitcoin-api/provider/mempool.service.ts index 99190626..e0977095 100644 --- a/backend/src/core/bitcoin-api/provider/mempool.service.ts +++ b/backend/src/core/bitcoin-api/provider/mempool.service.ts @@ -100,6 +100,11 @@ export class MempoolService implements IBitcoinDataProvider { } } + public async getAddress({ address }: { address: string }) { + const response = await this.mempool.bitcoin.addresses.getAddress({ address }); + return response; + } + public async getAddressTxsUtxo({ address }: { address: string }) { const response = await this.mempool.bitcoin.addresses.getAddressTxsUtxo({ address }); return response.map((utxo) => UTXO.parse(utxo)); diff --git a/backend/src/modules/bitcoin/address/address.dataloader.ts b/backend/src/modules/bitcoin/address/address.dataloader.ts new file mode 100644 index 00000000..cd7d1e9c --- /dev/null +++ b/backend/src/modules/bitcoin/address/address.dataloader.ts @@ -0,0 +1,92 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { NestDataLoader } from '@applifting-io/nestjs-dataloader'; +import { Address } from 'src/core/bitcoin-api/bitcoin-api.schema'; +import { BitcoinApiService } from 'src/core/bitcoin-api/bitcoin-api.service'; +import { DataLoaderResponse } from 'src/common/type/dataloader'; +import { BitcoinTransaction } from '../transaction/transaction.model'; + +@Injectable() +export class BitcoinAddressLoader implements NestDataLoader { + private logger = new Logger(BitcoinAddressLoader.name); + + constructor(private bitcoinApiService: BitcoinApiService) {} + + public getBatchFunction() { + return (addresses: string[]) => { + this.logger.debug(`Loading bitcoin addresses stats: ${addresses.join(', ')}`); + return Promise.all( + addresses.map((address) => this.bitcoinApiService.getAddress({ address })), + ); + }; + } +} +export type BitcoinAddressLoaderResponse = DataLoaderResponse; + +export interface BitcoinAddressBalance { + satoshi: number; + pendingSatoshi: number; +} + +@Injectable() +export class BitcoinAddressBalanceLoader implements NestDataLoader { + private logger = new Logger(BitcoinAddressBalanceLoader.name); + + constructor(private bitcoinApiService: BitcoinApiService) {} + + public getBatchFunction() { + return (addresses: string[]) => { + this.logger.debug(`Loading bitcoin addresses balance: ${addresses.join(', ')}`); + return Promise.all( + addresses.map(async (address) => { + // XXX: Miners has higher chance of getting this error: Too many unspent transaction outputs (>9000). Contact support to raise limits. + const utxos = await this.bitcoinApiService.getAddressTxsUtxo({ address }); + + let satoshi = 0; + let pendingSatoshi = 0; + utxos.forEach((utxo) => { + satoshi += utxo.value; + if (!utxo.status.confirmed) { + pendingSatoshi += utxo.value; + } + }); + return { + satoshi, + pendingSatoshi, + }; + }), + ); + }; + } +} +export type BitcoinAddressBalanceLoaderResponse = DataLoaderResponse; + +export interface BitcoinAddressTransactionsLoaderProps { + address: string; + afterTxid?: string; +} + +@Injectable() +export class BitcoinAddressTransactionsLoader + implements NestDataLoader +{ + private logger = new Logger(BitcoinAddressTransactionsLoader.name); + + constructor(private bitcoinApiService: BitcoinApiService) {} + + public getBatchFunction() { + return (batchProps: BitcoinAddressTransactionsLoaderProps[]) => { + this.logger.debug(`Loading bitcoin addresses txs: ${batchProps}`); + return Promise.all( + batchProps.map(async (props) => { + const txs = await this.bitcoinApiService.getAddressTxs({ + address: props.address, + after_txid: props.afterTxid, + }); + return txs.map((tx) => BitcoinTransaction.from(tx)); + }), + ); + }; + } +} +export type BitcoinAddressTransactionsLoaderResponse = + DataLoaderResponse; diff --git a/backend/src/modules/bitcoin/address/address.module.ts b/backend/src/modules/bitcoin/address/address.module.ts index db99ea65..bcd23455 100644 --- a/backend/src/modules/bitcoin/address/address.module.ts +++ b/backend/src/modules/bitcoin/address/address.module.ts @@ -1,7 +1,19 @@ import { Module } from '@nestjs/common'; +import { BitcoinApiModule } from 'src/core/bitcoin-api/bitcoin-api.module'; import { BitcoinAddressResolver } from './address.resolver'; +import { + BitcoinAddressLoader, + BitcoinAddressBalanceLoader, + BitcoinAddressTransactionsLoader, +} from './address.dataloader'; @Module({ - providers: [BitcoinAddressResolver], + imports: [BitcoinApiModule], + providers: [ + BitcoinAddressResolver, + BitcoinAddressLoader, + BitcoinAddressBalanceLoader, + BitcoinAddressTransactionsLoader, + ], }) export class AddressModule {} diff --git a/backend/src/modules/bitcoin/address/address.resolver.ts b/backend/src/modules/bitcoin/address/address.resolver.ts index 1e8697c3..9799edfd 100644 --- a/backend/src/modules/bitcoin/address/address.resolver.ts +++ b/backend/src/modules/bitcoin/address/address.resolver.ts @@ -1,32 +1,66 @@ -import { Float, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import DataLoader from 'dataloader'; +import { Loader } from '@applifting-io/nestjs-dataloader'; +import { Args, Float, Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { BitcoinApiService } from 'src/core/bitcoin-api/bitcoin-api.service'; import { BitcoinBaseTransaction, BitcoinTransaction } from '../transaction/transaction.model'; import { BitcoinAddress } from './address.model'; +import { + BitcoinAddressBalanceLoader, + BitcoinAddressBalanceLoaderResponse, + BitcoinAddressLoader, + BitcoinAddressLoaderResponse, + BitcoinAddressTransactionsLoader, + BitcoinAddressTransactionsLoaderProps, + BitcoinAddressTransactionsLoaderResponse, +} from './address.dataloader'; @Resolver(() => BitcoinAddress) export class BitcoinAddressResolver { + constructor(private bitcoinApiService: BitcoinApiService) {} + @ResolveField(() => Float) - public async satoshi(@Parent() address: BitcoinAddress): Promise { - // TODO: Implement this resolver - // get satoshi/pendingSatoshi from the address UTXOs - return 0; + public async satoshi( + @Parent() address: BitcoinAddress, + @Loader(BitcoinAddressBalanceLoader) + addressBalanceLoader: DataLoader, + ): Promise { + const { satoshi } = await addressBalanceLoader.load(address.address); + return satoshi; } @ResolveField(() => Float) - public async pendingSatoshi(@Parent() address: BitcoinAddress): Promise { - // TODO: Implement this Resolver - return 0; + public async pendingSatoshi( + @Parent() address: BitcoinAddress, + @Loader(BitcoinAddressBalanceLoader) + addressBalanceLoader: DataLoader, + ): Promise { + const { pendingSatoshi } = await addressBalanceLoader.load(address.address); + return pendingSatoshi; } @ResolveField(() => Float) - public async transactionCount(@Parent() address: BitcoinAddress): Promise { - // TODO: Implement this resolver - return 0; + public async transactionCount( + @Parent() address: BitcoinAddress, + @Loader(BitcoinAddressLoader) addressLoader: DataLoader, + ): Promise { + // TODO: addressInfo.mempool_stats.tx_count is not included in the response, not sure if it should be included + const stats = await addressLoader.load(address.address); + return stats.chain_stats.tx_count; } @ResolveField(() => [BitcoinTransaction]) - public async transactions(@Parent() address: BitcoinAddress): Promise { - // TODO: Implement this resolver - // use dataloaders to fetch transactions - return []; + public async transactions( + @Parent() address: BitcoinAddress, + @Loader(BitcoinAddressTransactionsLoader) + addressTxsLoader: DataLoader< + BitcoinAddressTransactionsLoaderProps, + BitcoinAddressTransactionsLoaderResponse + >, + @Args('afterTxid', { nullable: true }) afterTxid?: string, + ): Promise { + return await addressTxsLoader.load({ + address: address.address, + afterTxid: afterTxid, + }); } } diff --git a/backend/src/modules/bitcoin/block/block.dataloader.ts b/backend/src/modules/bitcoin/block/block.dataloader.ts index bd2e1b47..fe1e9957 100644 --- a/backend/src/modules/bitcoin/block/block.dataloader.ts +++ b/backend/src/modules/bitcoin/block/block.dataloader.ts @@ -38,7 +38,8 @@ export class BitcoinBlockTransactionsLoader public getBatchFunction() { return (hashes: string[]) => { - this.logger.debug(`Loading bitcoin blocks: ${hashes.join(', ')}`); + // TODO: this method only returns the first 25 transactions of each block + this.logger.debug(`Loading bitcoin block transactions: ${hashes.join(', ')}`); return Promise.all(hashes.map((hash) => this.bitcoinApiService.getBlockTxs({ hash }))); }; } diff --git a/backend/src/modules/bitcoin/block/block.resolver.ts b/backend/src/modules/bitcoin/block/block.resolver.ts index 723ec4b9..7745f241 100644 --- a/backend/src/modules/bitcoin/block/block.resolver.ts +++ b/backend/src/modules/bitcoin/block/block.resolver.ts @@ -23,26 +23,52 @@ export class BitcoinBlockResolver { } @ResolveField(() => BitcoinAddress) - public async miner(@Parent() block: BitcoinBaseBlock): Promise { - // TODO: Implement this resolver + public async miner( + @Parent() block: BitcoinBaseBlock, + @Loader(BitcoinBlockTransactionsLoader) + blockTxsLoader: DataLoader, + ): Promise { + const txs = await blockTxsLoader.load(block.id); + const coinbaseTx = BitcoinTransaction.from(txs[0]); return { - address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + address: coinbaseTx.vout[0].scriptpubkeyAddress, }; } @ResolveField(() => Float) - public async totalFee(@Parent() block: BitcoinBaseBlock): Promise { - // TODO: Implement this resolver - return 0; + public async totalFee( + @Parent() block: BitcoinBaseBlock, + @Loader(BitcoinBlockLoader) blockLoader: DataLoader, + ): Promise { + // XXX: only the "mempool" mode returns the "extra" field + const detail = await blockLoader.load(block.id); + if (detail.extras) { + return detail.extras.totalFees; + } else { + // TODO: what should be returned when using the "electrs" mode? + return 0; + } } @ResolveField(() => FeeRateRange) - public async feeRateRange(@Parent() block: BitcoinBaseBlock): Promise { - // TODO: Implement this resolver - return { - min: 0, - max: 1, - }; + public async feeRateRange( + @Parent() block: BitcoinBaseBlock, + @Loader(BitcoinBlockLoader) blockLoader: DataLoader, + ): Promise { + // XXX: only the "mempool" mode returns the "extra" field + const detail = await blockLoader.load(block.id); + if (detail.extras) { + return { + min: detail.extras.feeRange[0], + max: detail.extras.feeRange[detail.extras.feeRange.length - 1], + }; + } else { + // TODO: what should be returned when using the "electrs" mode? + return { + min: 0, + max: 0, + }; + } } @ResolveField(() => [BitcoinTransaction]) diff --git a/backend/src/modules/bitcoin/output/output.resolver.ts b/backend/src/modules/bitcoin/output/output.resolver.ts index fb9666ee..37e4de78 100644 --- a/backend/src/modules/bitcoin/output/output.resolver.ts +++ b/backend/src/modules/bitcoin/output/output.resolver.ts @@ -5,10 +5,13 @@ import { BitcoinOutput } from './output.model'; @Resolver(() => BitcoinOutput) export class BitcoinOutputResolver { @ResolveField(() => BitcoinAddress) - public async address(@Parent() output: BitcoinOutput): Promise { - // TODO: Implement this resolver + public async address(@Parent() output: BitcoinOutput): Promise { + // XXX: OP_RETURN outputs don't have address + if (!output.scriptpubkeyAddress) { + return null; + } return { - address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', + address: output.scriptpubkeyAddress, }; } } diff --git a/backend/src/modules/rgbpp/transaction/transaction.resolver.ts b/backend/src/modules/rgbpp/transaction/transaction.resolver.ts index 4f192a16..5a3a2bf3 100644 --- a/backend/src/modules/rgbpp/transaction/transaction.resolver.ts +++ b/backend/src/modules/rgbpp/transaction/transaction.resolver.ts @@ -31,6 +31,8 @@ export class RgbppTransactionResolver { public async getTransaction( @Args('txidOrTxHash') txidOrTxHash: string, ): Promise { + // FIXME: not sure if the txidOrTxHash is a ckb txHash or a btc txid + // 'ck' is a ckbAddress prefix, not a txid prefix. return txidOrTxHash.startsWith('ck') ? this.transactionService.getTransactionByCkbTxHash(txidOrTxHash) : this.transactionService.getTransactionByBtcTxid(txidOrTxHash); diff --git a/backend/src/schema.gql b/backend/src/schema.gql index 3ee2f684..6ef97b26 100644 --- a/backend/src/schema.gql +++ b/backend/src/schema.gql @@ -65,7 +65,7 @@ type BitcoinAddress { satoshi: Float! pendingSatoshi: Float! transactionCount: Float! - transactions: [BitcoinTransaction!]! + transactions(afterTxid: String): [BitcoinTransaction!]! } """Bitcoin Output"""