Skip to content

Commit

Permalink
feat: add bns v2 client and zonefile request
Browse files Browse the repository at this point in the history
  • Loading branch information
alter-eggo authored and kyranjamie committed Nov 13, 2024
1 parent 9b342af commit b4ec0e5
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 43 deletions.
3 changes: 3 additions & 0 deletions packages/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,3 +89,6 @@ export * from './types/api-types';
export * from './types/inscription';
export * from './types/remote-config';
export * from './types/utxo';
export * from './src/stacks/bns/bns-v2-client';
export * from './src/stacks/bns/bns.utils';
export * from './src/stacks/bns/bns.query';
5 changes: 5 additions & 0 deletions packages/query/src/query-prefixes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,8 @@ export enum StacksQueryPrefixes {
GetContractInterface = 'get-contract-interface',
GetAccountBalance = 'get-account-balance',
}

export enum BnsV2QueryPrefixes {
GetBnsNamesByAddress = 'get-bns-names-by-address',
GetBnsV2ZoneFileData = 'get-bns-v2-zone-file-data',
}
47 changes: 47 additions & 0 deletions packages/query/src/stacks/bns/bns-v2-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import axios from 'axios';

import { bitcoinNetworkModeToCoreNetworkMode } from '@leather.io/bitcoin';
import { BNS_V2_API_BASE_URL } from '@leather.io/models';
import { whenNetwork } from '@leather.io/utils';

import { useLeatherNetwork } from '../../leather-query-provider';
import {
BnsV2NamesByAddressResponse,
BnsV2ZoneFileDataResponse,
bnsV2NamesByAddressSchema,
bnsV2ZoneFileDataSchema,
} from './bns.schemas';

export type BnsV2Client = ReturnType<typeof bnsV2Client>;

/**
* @see https://bnsv2.com/docs
*/
export function bnsV2Client(basePath = BNS_V2_API_BASE_URL) {
return {
async getNamesByAddress(address: string, signal?: AbortSignal) {
const resp = await axios.get<BnsV2NamesByAddressResponse>(
`${basePath}/names/address/${address}/valid`,
{ signal }
);
return bnsV2NamesByAddressSchema.parse(resp.data);
},
async getZoneFileData(bnsName: string, signal?: AbortSignal) {
const resp = await axios.get<BnsV2ZoneFileDataResponse>(
`${basePath}/resolve-name/${bnsName}`,
{ signal }
);
return bnsV2ZoneFileDataSchema.parse(resp.data.zonefile);
},
};
}

export function useBnsV2Client() {
const network = useLeatherNetwork();
const basePath = whenNetwork(bitcoinNetworkModeToCoreNetworkMode(network.chain.bitcoin.mode))({
mainnet: BNS_V2_API_BASE_URL,
// TODO: Add testnet support if there will be a testnet BNSv2 API
testnet: BNS_V2_API_BASE_URL,
});
return bnsV2Client(basePath);
}
33 changes: 29 additions & 4 deletions packages/query/src/stacks/bns/bns.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { QueryFunctionContext, useQuery } from '@tanstack/react-query';
import { NetworkModes } from '@leather.io/models';

import { useCurrentNetworkState } from '../../leather-query-provider';
import { StacksQueryPrefixes } from '../../query-prefixes';
import { BnsV2QueryPrefixes } from '../../query-prefixes';
import { BnsV2Client, useBnsV2Client } from './bns-v2-client';
import { fetchNamesForAddress } from './bns.utils';

const staleTime = 24 * 60 * 60 * 1000; // 24 hours
Expand All @@ -19,22 +20,46 @@ const queryOptions = {
interface CreateGetBnsNamesOwnedByAddressQueryOptionsArgs {
address: string;
network: NetworkModes;
client: BnsV2Client;
}

export function createGetBnsNamesOwnedByAddressQueryOptions({
address,
network,
client,
}: CreateGetBnsNamesOwnedByAddressQueryOptionsArgs) {
return {
enabled: address !== '',
queryKey: [StacksQueryPrefixes.GetBnsNamesByAddress, address],
queryKey: [BnsV2QueryPrefixes.GetBnsNamesByAddress, address],
queryFn: async ({ signal }: QueryFunctionContext) =>
fetchNamesForAddress({ address, network, signal }),
fetchNamesForAddress({ address, network, signal, client }),
...queryOptions,
} as const;
}

export function useGetBnsNamesOwnedByAddressQuery(address: string) {
const { mode } = useCurrentNetworkState();
const client = useBnsV2Client();

return useQuery(createGetBnsNamesOwnedByAddressQueryOptions({ address, network: mode, client }));
}

interface CreateGetBnsV2ZoneFileDataQueryOptionsArgs {
bnsName: string;
client: BnsV2Client;
}
export function createGetBnsV2ZoneFileDataQueryOptions({
bnsName,
client,
}: CreateGetBnsV2ZoneFileDataQueryOptionsArgs) {
return {
queryKey: [BnsV2QueryPrefixes.GetBnsV2ZoneFileData, bnsName],
queryFn: async ({ signal }: QueryFunctionContext) => client.getZoneFileData(bnsName, signal),
...queryOptions,
} as const;
}

return useQuery(createGetBnsNamesOwnedByAddressQueryOptions({ address, network: mode }));
export function useGetBnsV2ZoneFileDataQuery(bnsName: string) {
const client = useBnsV2Client();
return useQuery(createGetBnsV2ZoneFileDataQueryOptions({ bnsName, client }));
}
17 changes: 17 additions & 0 deletions packages/query/src/stacks/bns/bns.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,20 @@ export const bnsV2NamesByAddressSchema = z.object({
offset: z.number(),
names: z.array(bnsV2NameSchema),
});

export type BnsV2NamesByAddressResponse = z.infer<typeof bnsV2NamesByAddressSchema>;

export const bnsV2ZoneFileDataSchema = z.object({
owner: z.string(),
general: z.string(),
twitter: z.string(),
url: z.string(),
nostr: z.string(),
lightning: z.string(),
btc: z.string(),
subdomains: z.array(z.string()),
});

export interface BnsV2ZoneFileDataResponse {
zonefile: z.infer<typeof bnsV2ZoneFileDataSchema>;
}
29 changes: 18 additions & 11 deletions packages/query/src/stacks/bns/bns.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import axios from 'axios';
import { describe, expect, it, vi } from 'vitest';

import { getPrimaryName } from './bns-v2-sdk';
Expand All @@ -11,31 +10,38 @@ describe('bns.utils', () => {
describe('fetchNamesForAddress', () => {
const mockAddress = 'ST123';
const mockSignal = new AbortController().signal;
const mockClient = {
getNamesByAddress: vi.fn(),
getZoneFileData: vi.fn(),
};

beforeEach(() => {
vi.clearAllMocks();
});

it('returns single name without fetching primary name', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: { names: [{ full_name: 'test.btc' }] },
mockClient.getNamesByAddress.mockResolvedValueOnce({
names: [{ full_name: 'test.btc' }],
});

const result = await fetchNamesForAddress({
address: mockAddress,
signal: mockSignal,
network: 'mainnet',
client: mockClient,
});

expect(result).toEqual({ names: ['test.btc'] });
expect(getPrimaryName).not.toHaveBeenCalled();
});

it('orders primary name first when multiple names exist', async () => {
vi.mocked(axios.get).mockResolvedValueOnce({
data: {
names: [
{ full_name: 'secondary.btc' },
{ full_name: 'primary.btc' },
{ full_name: 'another.btc' },
],
},
mockClient.getNamesByAddress.mockResolvedValueOnce({
names: [
{ full_name: 'secondary.btc' },
{ full_name: 'primary.btc' },
{ full_name: 'another.btc' },
],
});

vi.mocked(getPrimaryName).mockResolvedValueOnce({
Expand All @@ -47,6 +53,7 @@ describe('bns.utils', () => {
address: mockAddress,
signal: mockSignal,
network: 'mainnet',
client: mockClient,
});

expect(result).toEqual({
Expand Down
51 changes: 23 additions & 28 deletions packages/query/src/stacks/bns/bns.utils.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { parseZoneFile } from '@fungible-systems/zone-file';
import { BnsNamesOwnByAddressResponse } from '@stacks/stacks-blockchain-api-types';
import axios from 'axios';
import { z } from 'zod';

import { BNS_V2_API_BASE_URL, NetworkModes } from '@leather.io/models';
import { NetworkModes } from '@leather.io/models';
import { isString, isUndefined } from '@leather.io/utils';

import { StacksClient } from '../stacks-client';
import { BnsV2Client } from './bns-v2-client';
import { getPrimaryName } from './bns-v2-sdk';
import { bnsV2NamesByAddressSchema } from './bns.schemas';

/**
* Fetch names owned by an address.
Expand All @@ -17,10 +14,9 @@ interface FetchNamesForAddressArgs {
address: string;
network: NetworkModes;
signal: AbortSignal;
client: BnsV2Client;
}

type BnsV2NamesByAddressResponse = z.infer<typeof bnsV2NamesByAddressSchema>;

async function fetchPrimaryName(address: string, network: NetworkModes) {
try {
const res = await getPrimaryName({ address, network });
Expand All @@ -35,13 +31,11 @@ export async function fetchNamesForAddress({
address,
signal,
network,
client,
}: FetchNamesForAddressArgs): Promise<BnsNamesOwnByAddressResponse> {
const res = await axios.get<BnsV2NamesByAddressResponse>(
`${BNS_V2_API_BASE_URL}/names/address/${address}/valid`,
{ signal }
);
const res = await client.getNamesByAddress(address, signal);

const namesResponse = res.data.names.map(name => name.full_name);
const namesResponse = res.names.map(name => name.full_name);

// If the address owns multiple names, we need to fetch the primary name from SDK
let primaryName: string | undefined;
Expand Down Expand Up @@ -80,25 +74,26 @@ export async function fetchNameOwner(client: StacksClient, name: string, isTestn
return fetchFromApi();
}

/**
* Fetch the zonefile-based BTC address for a specific name.
* The BTC address is found via the `_btc._addr` TXT record,
* as specified in https://www.newinternetlabs.com/blog/standardizing-names-for-bitcoin-addresses/
*
* The value returned from this function is not validated.
*/
export async function fetchBtcNameOwner(
client: StacksClient,
name: string
client: BnsV2Client,
bnsName: string
): Promise<string | null> {
try {
const zoneFileData = await client.getZoneFileData(bnsName);
return zoneFileData.btc ?? null;
} catch (error) {
// Name not found or invalid zonefile
return null;
}
}

export async function fetchStacksNameOwner(
client: BnsV2Client,
bnsName: string
): Promise<string | null> {
try {
const nameResponse = await client.getNameInfo(name);
const zonefile = parseZoneFile(nameResponse.zonefile);
if (!zonefile.txt) return null;
const btcRecord = zonefile.txt.find(record => record.name === '_btc._addr');
if (isUndefined(btcRecord)) return null;
const txtValue = btcRecord.txt;
return isString(txtValue) ? txtValue : (txtValue[0] ?? null);
const zoneFileData = await client.getZoneFileData(bnsName);
return zoneFileData.owner ?? null;
} catch (error) {
// Name not found or invalid zonefile
return null;
Expand Down

0 comments on commit b4ec0e5

Please sign in to comment.