Skip to content

Commit

Permalink
gql: ordinals data feed for btc wallets (#4249)
Browse files Browse the repository at this point in the history
* add magic eden client for ordinals api

* add ordinals to the bitcoin provider

* cleanup ordinals impl
  • Loading branch information
callensm authored Jun 29, 2023
1 parent b782f25 commit 024c5d7
Show file tree
Hide file tree
Showing 15 changed files with 363 additions and 191 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/backend-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,11 @@ jobs:
printf 'COINGECKO_API_KEY=%s\n' '${{secrets.COINGECKO_API_KEY}}' >> .env
printf 'HASURA_URL=%s\n' '${{secrets.HASURA_URL}}' >> .env
printf 'HELIUS_API_KEY=%s\n' '${{secrets.HELIUS_API_KEY}}' >> .env
printf 'MAGIC_EDEN_API_KEY=%s\n' '${{secrets.MAGIC_EDEN_API_KEY}}' >> .env
printf 'OVERRIDE_RPC_URL=%s\n' '${{secrets.OVERRIDE_RPC_URL}}' >> .env
printf 'REDIS_URL=%s\n' '${{secrets.REDIS_URL}}' >> .env
printf 'SENTRY_DSN=%s\n' '${{secrets.SENTRY_DSN}}' >> .env
printf 'TENSOR_API_KEY=%s\n' '${{secrets.TENSOR_API_KEY}}' >> .env
printf 'TWITTER_CONSUMER_KEY=%s\n' '${{secrets.TWITTER_CONSUMER_KEY}}' >> .env
printf 'TWITTER_CONSUMER_SECRET=%s\n' '${{secrets.TWITTER_CONSUMER_SECRET}}' >> .env
printf 'VAPID_PRIVATE_KEY=%s\n' '${{secrets.VAPID_PRIVATE_KEY}}' >> .env
printf 'VAPID_PUBLIC_KEY=%s\n' '${{secrets.VAPID_PUBLIC_KEY}}' >> .env
cd ../../..
Expand Down
3 changes: 1 addition & 2 deletions backend/native/backpack-api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ COINGECKO_API_KEY=
HELIUS_API_KEY=
ALCHEMY_API_KEY=
TENSOR_API_KEY=
TWITTER_CONSUMER_KEY=
TWITTER_CONSUMER_SECRET=
MAGIC_EDEN_API_KEY=
6 changes: 1 addition & 5 deletions backend/native/backpack-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"start": "npm run build && node dist/index.js"
},
"dependencies": {
"@apollo/datasource-rest": "^5.1.0",
"@apollo/datasource-rest": "^6.0.1",
"@apollo/server": "^4.7.0",
"@apollo/server-plugin-response-cache": "^4.1.2",
"@apollo/utils.createhash": "^3.0.0",
Expand Down Expand Up @@ -53,8 +53,6 @@
"jsonwebtoken": "^8.5.1",
"jws": "^4.0.0",
"lru-cache": "^9.1.1",
"passport": "^0.6.0",
"passport-twitter": "^1.0.4",
"redis": "^4.6.2",
"request": "^2.88.2",
"tweetnacl": "^1.0.3",
Expand All @@ -68,8 +66,6 @@
"@graphql-codegen/typescript": "^3.0.3",
"@graphql-codegen/typescript-resolvers": "^3.2.0",
"@types/express": "^4.17.14",
"@types/passport": "^1.0.12",
"@types/passport-twitter": "^1.0.37",
"@types/redis": "^4.0.11",
"@types/request": "^2.48.8",
"@types/web-push": "^3.3.2",
Expand Down
1 change: 1 addition & 0 deletions backend/native/backpack-api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const COINGECKO_API_KEY = process.env.COINGECKO_API_KEY || "";
export const HELIUS_API_KEY = process.env.HELIUS_API_KEY || "";
export const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY || "";
export const TENSOR_API_KEY = process.env.TENSOR_API_KEY || "";
export const MAGIC_EDEN_API_KEY = process.env.MAGIC_EDEN_API_KEY || "";

export const DROPZONE_XNFT_SECRET = process.env.DROPZONE_XNFT_SECRET || "";
export const DROPZONE_PERMITTED_AUTHORITIES =
Expand Down
2 changes: 0 additions & 2 deletions backend/native/backpack-api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import proxyRouter from "./routes/v1/proxy";
import publicKeysRouter from "./routes/v1/public-keys";
import referralsRouter from "./routes/v1/referrals";
import s3Router from "./routes/v1/s3";
import twitterRouter from "./routes/v1/twitter";
import txParsingRouter from "./routes/v1/tx-parsing";
import usersRouter from "./routes/v1/users";
import { zodErrorToString } from "./util";
Expand Down Expand Up @@ -85,7 +84,6 @@ apollo.start().then(async () => {
app.use("/publicKeys", publicKeysRouter);
app.use("/referrals", referralsRouter);
app.use("/s3", s3Router);
app.use("/twitter", twitterRouter);
app.use("/tx-parsing", txParsingRouter);
app.use("/users", usersRouter);
app.use("/mobile", mobileRouter);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { PublicKey } from "@solana/web3.js";
import type { EnrichedTransaction } from "helius-sdk";
import { LRUCache } from "lru-cache";

export const IN_MEM_COLLECTION_DATA_CACHE = new LRUCache<
export const IN_MEM_SOL_COLLECTION_DATA_CACHE = new LRUCache<
string,
{ name?: string; image?: string }
>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export { BlockchainInfo } from "./blockchainInfo";
export { CoinGeckoIndexer } from "./coingecko";
export { Hasura } from "./hasura";
export { Helius } from "./helius";
export { MagicEden } from "./magiceden";
export { Swr } from "./swr";
export { Tensor } from "./tensor";
179 changes: 179 additions & 0 deletions backend/native/backpack-api/src/routes/graphql/clients/magiceden.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { RESTDataSource } from "@apollo/datasource-rest";
import { LRUCache } from "lru-cache";

export const IN_MEM_ORD_COLLECTION_DATA_CACHE = new LRUCache<string, any>({
allowStale: false,
max: 1000,
ttl: 1000 * 60 * 30, // 30 minute TTL
ttlAutopurge: true,
});

type MagicEdenOptions = {
apiKey: string;
};

/**
* Custom GraphQL REST data source for the MagicEden API.
* @export
* @class MagicEden
* @extends {RESTDataSource}
*/
export class MagicEden extends RESTDataSource {
readonly #apiKey: string;

override baseURL = "https://api-mainnet.magiceden.dev";

constructor(opts: MagicEdenOptions) {
super();
this.#apiKey = opts.apiKey;
}

/**
* Get the ordinals owned by the argued Bitcoin wallet address.
* @param {string} address
* @returns {Promise<MagicEdenGetOrdinalsByOwnerResponse>}
* @memberof MagicEden
*/
async getOrdinalsByOwner(
address: string
): Promise<MagicEdenGetOrdinalsByOwnerResponse> {
return this.get("/v2/ord/btc/tokens", {
params: {
ownerAddress: address,
showAll: "true",
sortBy: "inscriptionNumberDesc",
},
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.#apiKey}`,
},
});
}

/**
* Get the collection data for the argued ordinal collection symbols.
* @param {Set<string>} symbols
* @returns {Promise<MagicEdenGetOrdinalCollectionResponse>}
* @memberof MagicEden
*/
async getOrdinalCollections(
symbols: Set<string>
): Promise<MagicEdenGetOrdinalCollectionResponse> {
const syms = [...symbols.values()];
const notInCache = syms.filter(
(s) => !IN_MEM_ORD_COLLECTION_DATA_CACHE.has(s)
);

if (notInCache.length > 0) {
const responses: MagicEdenGetOrdinalCollectionResponse[string][] =
await Promise.all(
notInCache.map((s) =>
this.get(`/v2/ord/btc/collections/${s}`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${this.#apiKey}`,
},
})
)
);

for (const res of responses) {
IN_MEM_ORD_COLLECTION_DATA_CACHE.set(res.symbol, res);
}
}

return syms.reduce<MagicEdenGetOrdinalCollectionResponse>((acc, curr) => {
const data = IN_MEM_ORD_COLLECTION_DATA_CACHE.get(curr);
if (data) {
acc[curr] = data;
}
return acc;
}, {});
}

/**
* Get the URL of a listed ordinal.
* @param {string} inscription
* @returns {string}
*/
getOrdinalListingUrl(inscription: string): string {
return `https://magiceden.io/ordinals/item-details/${inscription}`;
}
}

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

export type MagicEdenGetOrdinalsByOwnerResponse = {
total: number;
tokens: Array<{
id: string;
chain: string;
collection?: {
chain: string;
description?: string;
imageURI?: string;
inscriptionIcon?: string;
name?: string;
symbol?: string;
};
contentURI: string;
contentType: string;
contentBody: string;
contentPreviewURI: string;
sat: number;
satName: string;
satRarity: string;
genesisTransaction: string;
genesisTransactionBlockTime: string;
genesisTransactionBlockHeight: number;
genesisTransactionBlockHash: string;
inscriptionNumber: number;
meta?: {
name?: string;
attributes?: Array<{
trait_type: string;
value: string;
}>;
};
owner: string;
collectionSymbol?: string;
location: string;
locationBlockHeight: number;
locationBlockTime: string;
locationBlockHash: string;
outputValue: number;
output: string;
mempoolTxId: string;
mempoolTxTimestamp: string;
listed: boolean;
listedAt: string;
listedPrice: number;
listedMakerFeeBp: number;
listedSellerReceiverAddress: string;
listedForMint: boolean;
brc20TransferAmt: number;
brc20ListedUnitPrice: number;
domain: string;
}>;
};

export type MagicEdenGetOrdinalCollectionResponse = Record<
string,
{
symbol: string;
name: string;
imageURI: string;
chain: string;
description: string;
supply: number;
twitterLink: string;
discordLink: string;
websiteLink: string;
min_inscription_number: number;
max_inscription_number: number;
createdAt: string;
inscriptionIcon?: string;
}
>;
17 changes: 8 additions & 9 deletions backend/native/backpack-api/src/routes/graphql/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,7 @@ import {
TENSOR_API_KEY,
} from "../../config";

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

const IN_MEM_JWT_CACHE = new LRUCache<string, string>({
Expand All @@ -27,22 +21,28 @@ const IN_MEM_JWT_CACHE = new LRUCache<string, string>({
});

export interface ApiContext {
// Requesting user authorization and identity data
authorization: {
jwt?: string;
userId?: string;
valid: boolean;
};
// Data source clients that are common to more than one
// type of provider. If a data source is proprietary to a
// single provider, if should be instantiated only in that
// provider's constructor and not placed in the common context
dataSources: {
blockchainInfo: BlockchainInfo;
coinGecko: CoinGeckoIndexer;
hasura: Hasura;
swr: Swr;
tensor: Tensor;
};
// The original raw HTTP request and response object from express
http: {
req: Request;
res: Response;
};
// Custom network options parsed from the request headers
network: {
devnet: boolean;
rpc?: string;
Expand Down Expand Up @@ -90,7 +90,6 @@ export const createContext: ContextFunction<
valid,
},
dataSources: {
blockchainInfo: new BlockchainInfo({}),
coinGecko: new CoinGeckoIndexer({ apiKey: COINGECKO_API_KEY }),
hasura: new Hasura({ secret: HASURA_JWT, url: HASURA_URL }),
swr: new Swr({}),
Expand Down
12 changes: 8 additions & 4 deletions backend/native/backpack-api/src/routes/graphql/nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export abstract class NodeBuilder {
);
}

static nftListing(
providerId: ProviderId,
address: string,
data: Omit<Listing, "id">
): Listing {
return this._createNode(`${providerId}_nft_listing:${address}`, data);
}

static notification(data: Omit<Notification, "id">): Notification {
return this._createNode(`notification:${data.dbId}`, data);
}
Expand All @@ -89,10 +97,6 @@ export abstract class NodeBuilder {
return this._createNode(`provider:${data.providerId}`, data);
}

static tensorListing(mint: string, data: Omit<Listing, "id">): Listing {
return this._createNode(`tensor_active_listing:${mint}`, data);
}

static tokenBalance(
providerId: ProviderId,
data: Omit<TokenBalance, "id">,
Expand Down
Loading

1 comment on commit 024c5d7

@vercel
Copy link

@vercel vercel bot commented on 024c5d7 Jun 29, 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.