Skip to content

Commit

Permalink
gql: btc data provider (#4200)
Browse files Browse the repository at this point in the history
* add btc provider skeleton

* add btc data provider for txs and balance

* add btc to token list in common

* add btc transaction offset and pagination
  • Loading branch information
callensm authored Jun 21, 2023
1 parent c6cea0e commit 5d8531f
Show file tree
Hide file tree
Showing 12 changed files with 413 additions and 42 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { RESTDataSource } from "@apollo/datasource-rest";

type BlockchainInfoOptions = {};

/**
* Custom GraphQL REST data source class abstraction for Blockchain Info API.
* @export
* @class BlockchainInfo
* @extends {RESTDataSource}
*/
export class BlockchainInfo extends RESTDataSource {
override baseURL = "https://blockchain.info";

constructor(_opts: BlockchainInfoOptions) {
super();
}

/**
* Get the Bitcoin balance data for the argued wallet address.
* @param {string} address
* @returns {Promise<BlockchainInfoBalanceResponse>}
* @memberof BlockchainInfo
*/
async getBalance(address: string): Promise<BlockchainInfoBalanceResponse> {
const resp: Record<string, BlockchainInfoBalanceResponse> = await this.get(
`/balance?active=${address}`
);

if (!resp[address]) {
throw new Error(`no balance data found for ${address}`);
}

return resp[address];
}

/**
* Return the recent transactions for a Bitcoin wallet address.
* @param {string} address
* @param {number} [transactionOffset]
* @returns {Promise<BlockchainInfoTransactionsResponse>}
* @memberof BlockchainInfo
*/
async getRawAddressData(
address: string,
transactionOffset?: number
): Promise<BlockchainInfoTransactionsResponse> {
return this.get(`/rawaddr/${address}`, {
params: transactionOffset
? {
offset: transactionOffset.toString(),
}
: undefined,
});
}
}

////////////////////////////////////////////
// Types //
////////////////////////////////////////////

type BlockchainInfoBalanceResponse = {
final_balance: number;
n_tx: number;
total_received: number;
};

type BlockchainInfoTransactionsResponse = {
hash160: string;
address: string;
n_tx: number;
n_unredeemed: number;
total_received: number;
total_sent: number;
final_balance: number;
txs: Array<{
hash: string;
ver: number;
vin_sz: number;
vout_sz: number;
size: number;
weight: number;
fee: number;
relayed_by: string;
lock_time: number;
tx_index: number;
double_spend: boolean;
time: number;
block_index: number;
block_height: number;
result: number;
balance: number;
inputs: Array<{
sequence: number;
witness: string;
script: string;
index: number;
prev_out: {
addr: string;
n: number;
script: string;
spending_outpoints: Array<{
n: number;
tx_index: number;
}>;
spent: boolean;
tx_index: number;
type: number;
value: number;
};
}>;
out: Array<{
type: number;
spent: boolean;
value: number;
spending_outpoints: any[];
n: number;
tx_index: number;
script: string;
addr: string;
}>;
}>;
};
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { BlockchainInfo } from "./blockchainInfo";
export { CoinGeckoIndexer } from "./coingecko";
export { Hasura } from "./hasura";
export { Helius } from "./helius";
Expand Down
11 changes: 10 additions & 1 deletion backend/native/backpack-api/src/routes/graphql/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@ import {
TENSOR_API_KEY,
} from "../../config";

import { CoinGeckoIndexer, Hasura, Helius, Swr, Tensor } from "./clients";
import {
BlockchainInfo,
CoinGeckoIndexer,
Hasura,
Helius,
Swr,
Tensor,
} from "./clients";
import { extractJwt, getSubjectFromVerifiedJwt } from "./utils";

const IN_MEM_JWT_CACHE = new LRUCache<string, string>({
Expand All @@ -31,6 +38,7 @@ export interface ApiContext {
};
dataSources: {
alchemy: Alchemy;
blockchainInfo: BlockchainInfo;
coinGecko: CoinGeckoIndexer;
hasura: Hasura;
helius: Helius;
Expand Down Expand Up @@ -92,6 +100,7 @@ export const createContext: ContextFunction<
apiKey: ALCHEMY_API_KEY,
network: devnet ? Network.ETH_SEPOLIA : Network.ETH_MAINNET,
}),
blockchainInfo: new BlockchainInfo({}),
coinGecko: new CoinGeckoIndexer({ apiKey: COINGECKO_API_KEY }),
hasura: new Hasura({ secret: HASURA_JWT, url: HASURA_URL }),
helius: new Helius({ apiKey: HELIUS_API_KEY, devnet }),
Expand Down
201 changes: 201 additions & 0 deletions backend/native/backpack-api/src/routes/graphql/providers/bitcoin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { BitcoinToken } from "@coral-xyz/common";
import { ethers } from "ethers";

import type { ApiContext } from "../context";
import { NodeBuilder } from "../nodes";
import {
type BalanceFiltersInput,
type Balances,
type NftConnection,
type NftFiltersInput,
ProviderId,
type TokenBalance,
type Transaction,
type TransactionConnection,
type TransactionFiltersInput,
} from "../types";
import { calculateBalanceAggregate, createConnection } from "../utils";

import type { BlockchainDataProvider } from ".";

/**
* Bitcoin blockchain implementation for the common API.
* @export
* @class Bitcoin
* @implements {BlockchainDataProvider}
*/
export class Bitcoin implements BlockchainDataProvider {
readonly #ctx?: ApiContext;

constructor(ctx?: ApiContext) {
this.#ctx = ctx;
}

/**
* Chain ID enum variant.
* @returns {ProviderId}
* @memberof Bitcoin
*/
id(): ProviderId {
return ProviderId.Bitcoin;
}

/**
* Native coin decimals.
* @returns {number}
* @memberof Bitcoin
*/
decimals(): number {
return 8;
}

/**
* Default native address.
* @returns {string}
* @memberof Bitcoin
*/
defaultAddress(): string {
return BitcoinToken.address;
}

/**
* Logo of the native coin.
* @returns {string}
* @memberof Bitcoin
*/
logo(): string {
return BitcoinToken.logo!;
}

/**
* The display name of the data provider.
* @returns {string}
* @memberof Bitcoin
*/
name(): string {
return BitcoinToken.name;
}

/**
* Symbol of the native coin.
* @returns {string}
* @memberof Bitcoin
*/
symbol(): string {
return BitcoinToken.symbol;
}

/**
* Fetch and aggregate the native and prices for the argued wallet address.
* @param {string} address
* @param {BalanceFiltersInput} [_filters]
* @returns {Promise<Balances>}
* @memberof Bitcoin
*/
async getBalancesForAddress(
address: string,
_filters?: BalanceFiltersInput
): Promise<Balances> {
if (!this.#ctx) {
throw new Error("API context object not available");
}

const balance = await this.#ctx.dataSources.blockchainInfo.getBalance(
address
);

const prices = await this.#ctx.dataSources.coinGecko.getPrices(["bitcoin"]);
const displayAmount = ethers.utils.formatUnits(
balance.final_balance,
this.decimals()
);

const nodes: TokenBalance[] = [
NodeBuilder.tokenBalance(
this.id(),
{
address,
amount: balance.final_balance.toString(),
decimals: this.decimals(),
displayAmount,
marketData: prices?.bitcoin
? NodeBuilder.marketData("bitcoin", {
lastUpdatedAt: prices.bitcoin.last_updated,
percentChange: prices.bitcoin.price_change_percentage_24h,
price: prices.bitcoin.current_price,
sparkline: prices.bitcoin.sparkline_in_7d.price,
usdChange: prices.bitcoin.price_change_24h,
value: parseFloat(displayAmount) * prices.bitcoin.current_price,
valueChange:
parseFloat(displayAmount) * prices.bitcoin.price_change_24h,
})
: undefined,
token: this.defaultAddress(),
tokenListEntry: NodeBuilder.tokenListEntry({
address: this.defaultAddress(),
coingeckoId: "bitcoin",
logo: this.logo(),
name: this.name(),
symbol: this.symbol(),
}),
},
true
),
];

return NodeBuilder.balances(address, this.id(), {
aggregate: calculateBalanceAggregate(address, nodes),
tokens: createConnection(nodes, false, false),
});
}

/**
* Get a list of NFT data for tokens owned by the argued address.
* @param {string} _address
* @param {NftFiltersInput} [_filters]
* @returns {Promise<NftConnection>}
* @memberof Bitcoin
*/
async getNftsForAddress(
_address: string,
_filters?: NftFiltersInput | undefined
): Promise<NftConnection> {
return createConnection([], false, false);
}

/**
* Get the transaction history with parameters for the argued address.
* @param {string} address
* @param {TransactionFiltersInput} [filters]
* @returns {Promise<TransactionConnection>}
* @memberof Bitcoin
*/
async getTransactionsForAddress(
address: string,
filters?: TransactionFiltersInput
): Promise<TransactionConnection> {
if (!this.#ctx) {
throw new Error("API context object not available");
}

const resp = await this.#ctx.dataSources.blockchainInfo.getRawAddressData(
address,
filters?.offset ?? undefined
);

const nodes: Transaction[] = resp.txs.map((t) =>
NodeBuilder.transaction(this.id(), {
block: t.block_index,
fee: t.fee.toString(),
hash: t.hash,
raw: t,
timestamp: new Date(t.time).toISOString(),
type: "standard",
})
);

const hasNext = resp.n_tx > (filters?.offset ?? 0) + 50;
const hasPrevious = filters?.offset ? filters.offset > 0 : false;
return createConnection(nodes, hasNext, hasPrevious);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ export class Ethereum implements BlockchainDataProvider {
* Fetch and aggregate the native and token balances and
* prices for the argued wallet address.
* @param {string} address
* @param {Partial<BalanceFiltersInput>} [filters]
* @param {BalanceFiltersInput} [filters]
* @returns {Promise<Balances>}
* @memberof Ethereum
*/
async getBalancesForAddress(
address: string,
filters?: Partial<BalanceFiltersInput>
filters?: BalanceFiltersInput
): Promise<Balances> {
if (!this.#ctx) {
throw new Error("API context object not available");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type TransactionFiltersInput,
} from "../types";

import { Bitcoin } from "./bitcoin";
import { Ethereum } from "./ethereum";
import { Solana } from "./solana";

Expand Down Expand Up @@ -47,6 +48,9 @@ export function getProviderForId(
ctx?: ApiContext
): BlockchainDataProvider {
switch (id) {
case ProviderId.Bitcoin: {
return new Bitcoin(ctx);
}
case ProviderId.Ethereum: {
return new Ethereum(ctx);
}
Expand Down
Loading

1 comment on commit 5d8531f

@vercel
Copy link

@vercel vercel bot commented on 5d8531f Jun 21, 2023

Choose a reason for hiding this comment

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

Please sign in to comment.