Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(backend): fill backend todos #8

Merged
merged 5 commits into from
Jul 25, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/src/core/bitcoin-api/bitcoin-api.interface.ts
Original file line number Diff line number Diff line change
@@ -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<RecommendedFees>;
getAddress({ address }: { address: string }): Promise<Address>;
getAddressTxsUtxo({ address }: { address: string }): Promise<UTXO[]>;
getAddressTxs({
address,
Expand Down
26 changes: 26 additions & 0 deletions backend/src/core/bitcoin-api/bitcoin-api.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Block>;

Expand All @@ -34,6 +41,25 @@ export const Status = z.object({
});
export type Status = z.infer<typeof Status>;

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<typeof Address>;

export const Balance = z.object({
address: z.string(),
satoshi: z.number(),
Expand Down
4 changes: 4 additions & 0 deletions backend/src/core/bitcoin-api/bitcoin-api.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
7 changes: 6 additions & 1 deletion backend/src/core/bitcoin-api/provider/electrs.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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/${address}`);
return response.data;
}

public async getAddressTxsUtxo({ address }: { address: string }) {
const response = await this.request.get<UTXO[]>(`/address/${address}/utxo`);
return response.data;
Expand Down
5 changes: 5 additions & 0 deletions backend/src/core/bitcoin-api/provider/mempool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
92 changes: 92 additions & 0 deletions backend/src/modules/bitcoin/address/address.dataloader.ts
Original file line number Diff line number Diff line change
@@ -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<string, Address> {
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<BitcoinAddressLoader>;

export interface BitcoinAddressBalance {
satoshi: number;
pendingSatoshi: number;
}

@Injectable()
export class BitcoinAddressBalanceLoader implements NestDataLoader<string, BitcoinAddressBalance> {
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<BitcoinAddressBalanceLoader>;

export interface BitcoinAddressTransactionsLoaderProps {
address: string;
afterTxid?: string;
}

@Injectable()
export class BitcoinAddressTransactionsLoader
implements NestDataLoader<BitcoinAddressTransactionsLoaderProps, BitcoinTransaction[]>
{
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<BitcoinAddressTransactionsLoader>;
14 changes: 13 additions & 1 deletion backend/src/modules/bitcoin/address/address.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
64 changes: 49 additions & 15 deletions backend/src/modules/bitcoin/address/address.resolver.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
// TODO: Implement this resolver
// get satoshi/pendingSatoshi from the address UTXOs
return 0;
public async satoshi(
@Parent() address: BitcoinAddress,
@Loader(BitcoinAddressBalanceLoader)
addressBalanceLoader: DataLoader<string, BitcoinAddressBalanceLoaderResponse>,
): Promise<number> {
const { satoshi } = await addressBalanceLoader.load(address.address);
return satoshi;
}

@ResolveField(() => Float)
public async pendingSatoshi(@Parent() address: BitcoinAddress): Promise<number> {
// TODO: Implement this Resolver
return 0;
public async pendingSatoshi(
@Parent() address: BitcoinAddress,
@Loader(BitcoinAddressBalanceLoader)
addressBalanceLoader: DataLoader<string, BitcoinAddressBalanceLoaderResponse>,
): Promise<number> {
const { pendingSatoshi } = await addressBalanceLoader.load(address.address);
return pendingSatoshi;
}

@ResolveField(() => Float)
public async transactionCount(@Parent() address: BitcoinAddress): Promise<number> {
// TODO: Implement this resolver
return 0;
public async transactionCount(
@Parent() address: BitcoinAddress,
@Loader(BitcoinAddressLoader) addressLoader: DataLoader<string, BitcoinAddressLoaderResponse>,
): Promise<number> {
// XXX: 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);
Flouse marked this conversation as resolved.
Show resolved Hide resolved
return stats.chain_stats.tx_count;
}

@ResolveField(() => [BitcoinTransaction])
public async transactions(@Parent() address: BitcoinAddress): Promise<BitcoinBaseTransaction[]> {
// 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<BitcoinBaseTransaction[]> {
return await addressTxsLoader.load({
address: address.address,
afterTxid: afterTxid,
});
}
}
3 changes: 2 additions & 1 deletion backend/src/modules/bitcoin/block/block.dataloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })));
};
}
Expand Down
50 changes: 38 additions & 12 deletions backend/src/modules/bitcoin/block/block.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,52 @@ export class BitcoinBlockResolver {
}

@ResolveField(() => BitcoinAddress)
public async miner(@Parent() block: BitcoinBaseBlock): Promise<BitcoinBaseAddress> {
// TODO: Implement this resolver
public async miner(
@Parent() block: BitcoinBaseBlock,
@Loader(BitcoinBlockTransactionsLoader)
blockTxsLoader: DataLoader<string, BitcoinBlockTransactionsLoaderResponse>,
): Promise<BitcoinBaseAddress> {
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<number> {
// TODO: Implement this resolver
return 0;
public async totalFee(
@Parent() block: BitcoinBaseBlock,
@Loader(BitcoinBlockLoader) blockLoader: DataLoader<string, BitcoinBlockLoaderResponse>,
): Promise<number> {
// 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<FeeRateRange> {
// TODO: Implement this resolver
return {
min: 0,
max: 1,
};
public async feeRateRange(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using the feeRange field in the response of https://mempool.space/docs/api/rest#get-block?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored at: 502a512

@Parent() block: BitcoinBaseBlock,
@Loader(BitcoinBlockLoader) blockLoader: DataLoader<string, BitcoinBlockLoaderResponse>,
): Promise<FeeRateRange> {
// 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])
Expand Down
9 changes: 6 additions & 3 deletions backend/src/modules/bitcoin/output/output.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import { BitcoinOutput } from './output.model';
@Resolver(() => BitcoinOutput)
export class BitcoinOutputResolver {
@ResolveField(() => BitcoinAddress)
public async address(@Parent() output: BitcoinOutput): Promise<BitcoinBaseAddress> {
// TODO: Implement this resolver
public async address(@Parent() output: BitcoinOutput): Promise<BitcoinBaseAddress | null> {
// XXX: OP_RETURN outputs don't have address
if (!output.scriptpubkeyAddress) {
return null;
}
return {
address: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa',
address: output.scriptpubkeyAddress,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export class RgbppTransactionResolver {
public async getTransaction(
@Args('txidOrTxHash') txidOrTxHash: string,
): Promise<RgbppBaseTransaction | null> {
// FIXME: not sure if the txidOrTxHash is a ckb txHash or a btc txid
Flouse marked this conversation as resolved.
Show resolved Hide resolved
return txidOrTxHash.startsWith('ck')
? this.transactionService.getTransactionByCkbTxHash(txidOrTxHash)
: this.transactionService.getTransactionByBtcTxid(txidOrTxHash);
Expand Down
2 changes: 1 addition & 1 deletion backend/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ type BitcoinAddress {
satoshi: Float!
pendingSatoshi: Float!
transactionCount: Float!
transactions: [BitcoinTransaction!]!
transactions(afterTxid: String): [BitcoinTransaction!]!
}

"""Bitcoin Output"""
Expand Down
Loading