Skip to content

Commit

Permalink
Merge pull request #57 from milktoastlab/fix-magic-eden-sales-tracking
Browse files Browse the repository at this point in the history
Add support for MagicEden V2
kryptoj authored Nov 18, 2022
2 parents df78db4 + 65d2f66 commit e777105
Showing 11 changed files with 279 additions and 142 deletions.
8 changes: 8 additions & 0 deletions .env
Original file line number Diff line number Diff line change
@@ -7,6 +7,14 @@ SUBSCRIPTION_DISCORD_CHANNEL_ID=
# Mint address to watch for sales
# This variable supports multiple addressses with comma e.g. SUBSCRIPTION_MINT_ADDRESS=add123,add456
SUBSCRIPTION_MINT_ADDRESS=

# Magic eden API
MAGIC_EDEN_URL=https://api-mainnet.magiceden.dev/v2
# Enter the NFT collection that you want to track
MAGIC_EDEN_COLLECTION=
# The discord channel to notify
MAGIC_EDEN_DISCORD_CHANNEL_ID=

# Twitter secrets
TWITTER_API_KEY=
TWITTER_API_KEY_SECRET=
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -110,6 +110,12 @@ TWITTER_API_KEY=
TWITTER_API_KEY_SECRET=
TWITTER_ACCESS_TOKEN=
TWITTER_ACCESS_TOKEN_SECRET=
# Magic eden API
MAGIC_EDEN_URL=https://api-mainnet.magiceden.dev/v2
# Enter the NFT collection that you want to track
MAGIC_EDEN_COLLECTION=
# The discord channel to notify
MAGIC_EDEN_DISCORD_CHANNEL_ID=
```
https://github.com/milktoastlab/SolanaNFTBot/blob/main/.env

@@ -172,6 +178,24 @@ Then, click on the Keys and tokens tab, and generate the Access Token and Secret

<img src= https://user-images.githubusercontent.com/50549441/149973388-58f3a303-91f4-4e1b-ab7f-dfc2a22aa5da.png>

### Magic Eden variables
Magic eden's NFT trading program has changed to V2, which means the old way of detecting sales won't work anymore. We have updated the bot to use the new API to detect sales.
To enable this feature, you will need to add the following variables to your `.env` file:

__MAGIC_EDEN_COLLECTION__

This is the collection key to magic eden. To find our what it is, navigate to the collection page and look at the url. It should be the last part of the url.
```
Example:
https://magiceden.io/marketplace/milktoast
```
The collection key is "milktoast"

__MAGIC_EDEN_DISCORD_CHANNEL_ID__

This is the discord channel to notify. Same as `SUBSCRIPTION_DISCORD_CHANNEL_ID` but it doesn't support multiple channels at the moment.


## Production deployment

The solana nft bot is containerized, you can deploy it on any hosting service that supports docker.
12 changes: 12 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
@@ -13,11 +13,18 @@ interface TwitterConfig {
accessSecret: string;
}

export interface MagicEdenConfig {
url: string;
collection: string;
discordChannelId: string;
}

export interface Config {
twitter: TwitterConfig;
discordBotToken: string;
queueConcurrency: number;
subscriptions: Subscription[];
magicEdenConfig: MagicEdenConfig;
}

export type Env = { [key: string]: string };
@@ -76,6 +83,11 @@ export function loadConfig(env: Env): MutableConfig {
discordBotToken: env.DISCORD_BOT_TOKEN || "",
queueConcurrency: parseInt(env.QUEUE_CONCURRENCY || "2", 10),
subscriptions: loadSubscriptions(env),
magicEdenConfig: {
url: env.MAGIC_EDEN_URL || "",
collection: env.MAGIC_EDEN_COLLECTION || "",
discordChannelId: env.MAGIC_EDEN_DISCORD_CHANNEL_ID || "",
},
};

return {
126 changes: 0 additions & 126 deletions src/lib/marketplaces/magicEden.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import magicEden from "./magicEden";
import magicEdenSaleTx from "./__fixtures__/magicEdenSaleTx";
import magicEdenSaleFromBidTx from "./__fixtures__/magicEdenSaleFromBidTx";
import { SaleMethod } from "./types";
import { Connection } from "@solana/web3.js";

jest.mock("lib/solana/NFTData", () => {
return {
@@ -13,131 +9,9 @@ jest.mock("lib/solana/NFTData", () => {
});

describe("magicEden", () => {
const conn = new Connection("https://test/");

test("itemUrl", () => {
expect(magicEden.itemURL("xxx1")).toEqual(
"https://magiceden.io/item-details/xxx1"
);
});

describe("parseNFTSale", () => {
test("sale transaction should return NFTSale", async () => {
const sale = await magicEden.parseNFTSale(conn, magicEdenSaleTx);
expect(sale.transaction).toEqual(
"626EgwuS6dbUKrkZujQCFjHiRsz92ALR5gNAEg2eMpZzEo88Cci6HifpDFcvgYR8j88nXUq1nRUA7UDRdvB7Y6WD"
);
expect(sale.token).toEqual(
"8pwYVy61QiSTJGPc8yYfkVPLBBr8r17WkpUFRhNK6cjK"
);
expect(sale.soldAt).toEqual(new Date(1635141315000));
expect(sale.marketplace).toEqual(magicEden);
expect(sale.getPriceInLamport()).toEqual(3720000000);
expect(sale.getPriceInSOL()).toEqual(3.72);

const expectedTransfers = [
{
to: "2NZukH2TXpcuZP4htiuT8CFxcaQSWzkkR6kepSWnZ24Q",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 74400000,
symbol: "lamport",
},
},
{
to: "4eQwMqAA4c2VUD51rqfAke7kqeFLAxcxSB67rtFjDyZA",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 74400000,
symbol: "lamport",
},
},
{
to: "Dz9kwoBVVzF11cHeKotQpA7t4aeCQsgRpVw4dg8zkntg",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 74400000,
symbol: "lamport",
},
},
{
to: "4xHEEswq2T2E5uNoa1uw34RNKzPerayBHxX3P4SaR7cD",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 74400000,
symbol: "lamport",
},
},
{
to: "33CJriD17bUScYW7eKFjM6BPfkFWPerHfdpvtw3a8JdN",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 74400000,
symbol: "lamport",
},
},
{
to: "HWZybKNqMa93EmHK2ESL2v1XShcnt4ma4nFf14497jNS",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 74400000,
symbol: "lamport",
},
},
{
to: "HihC794BdNCetkizxdFjVD2KiKWirGYbm2ojvRYXQd6H",
from: "U7ZkJtaAwvBHt9Tw5BK8sdp2wLrEe7p1g3kFxB9WJCu",
revenue: {
amount: 3273600000,
symbol: "lamport",
},
},
];
expect(sale.transfers.length).toEqual(expectedTransfers.length);
expectedTransfers.forEach((expectedTransfer, index) => {
const transfer = sale.transfers[index];
expect(transfer.from).toEqual(expectedTransfer.from);
expect(transfer.to).toEqual(expectedTransfer.to);
expect(transfer.revenue).toEqual(expectedTransfer.revenue);
});
expect(sale.method).toEqual(SaleMethod.Direct);
expect(sale.seller).toEqual(
"HihC794BdNCetkizxdFjVD2KiKWirGYbm2ojvRYXQd6H"
);
});
test("bidding sale transaction should return NFTSale", async () => {
const sale = await magicEden.parseNFTSale(conn, magicEdenSaleFromBidTx);
expect(sale.transaction).toEqual(
"1cSgCBgot6w4KevVvsZc2PiST16BsEh9KAvmnbsSC9xXvput4SXLoq5pneQfczQEBw3jjcdmupG7Gp6MjG5MLzy"
);
expect(sale.token).toEqual(
"3SxS8hpvZ6BfHXwaURJAhtxXWbwnkUGA7HPV3b7uLnjN"
);
expect(sale.buyer).toEqual(
"2fT7A7iKwDodPj5rm4u4tXRFny9JY1ttHhHGp1PsvsAn"
);
expect(sale.method).toEqual(SaleMethod.Bid);
expect(sale.seller).toEqual(
"AJ3r8njrEnHnwmv2JmnXEYoy7EfsxWQq7UcnLUhjuVab"
);
});
test("non-sale transaction should return null", async () => {
const invalidSaleTx = {
...magicEdenSaleTx,
meta: {
...magicEdenSaleTx.meta,
preTokenBalances: [],
postTokenBalances: [],
},
};
expect(await magicEden.parseNFTSale(conn, invalidSaleTx)).toBe(null);
});
test("non magic eden transaction", async () => {
const nonMagicEdenSaleTx = {
...magicEdenSaleTx,
};
nonMagicEdenSaleTx.meta.logMessages = ["Program xxx invoke [1]"];
expect(await magicEden.parseNFTSale(conn, nonMagicEdenSaleTx)).toBe(null);
});
});
});
4 changes: 3 additions & 1 deletion src/lib/marketplaces/magicEden.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Marketplace, NFTSale } from "./types";
import { parseNFTSaleOnTx } from "./helper";
import { parseNFTSaleOnTx } from "lib/marketplaces/helper";

const magicEden: Marketplace = {
name: "Magic Eden",
@@ -10,6 +10,8 @@ const magicEden: Marketplace = {
iconURL: "https://www.magiceden.io/img/favicon.png",
itemURL: (token: String) => `https://magiceden.io/item-details/${token}`,
profileURL: (address: String) => `https://magiceden.io/u/${address}`,
// Deprecated MagicEden doesn't work with the existing ways of parsing NFT sales
// Detecting MagicEden now happens via their API
parseNFTSale(web3Conn, txResp): Promise<NFTSale | null> {
return parseNFTSaleOnTx(web3Conn, txResp, this);
},
2 changes: 0 additions & 2 deletions src/lib/marketplaces/parseNFTSaleForAllMarkets.test.ts
Original file line number Diff line number Diff line change
@@ -22,13 +22,11 @@ describe("parseNFTSale", () => {

test("sale transaction should return NFTSale", async () => {
const tests = [
magicEdenSaleTx,
digitalEyeSaleTx,
solanartSaleTx,
alphaArtSaleTx,
exchangeArtSaleTx,
solseaSaleTx,
magicEdenSaleTxV2,
openSeaSaleTx,
].map(async (tx) => {
const sale = await parseNFTSaleForAllMarkets(conn, tx);
6 changes: 5 additions & 1 deletion src/lib/marketplaces/parseNFTSaleForAllMarkets.ts
Original file line number Diff line number Diff line change
@@ -7,7 +7,11 @@ export default async function parseNFTSaleForAllMarkets(
tx: ParsedConfirmedTransaction
): Promise<NFTSale | null> {
for (let i = 0; i < marketplaces.length; i++) {
const nftSale = await marketplaces[i].parseNFTSale(web3Conn, tx);
const marketplace = marketplaces[i];
if (!marketplace.parseNFTSale) {
continue;
}
const nftSale = await marketplace.parseNFTSale(web3Conn, tx);
if (nftSale) {
return nftSale;
}
3 changes: 2 additions & 1 deletion src/lib/marketplaces/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Connection, ParsedConfirmedTransaction } from "@solana/web3.js";
import { MagicEdenConfig } from "config";
import NFTData from "lib/solana/NFTData";

export enum SaleMethod {
@@ -12,7 +13,7 @@ export interface Marketplace {
iconURL: string;
itemURL: (token: String) => string;
profileURL: (address: String) => string;
parseNFTSale: (
parseNFTSale?: (
web3Conn: Connection,
tx: ParsedConfirmedTransaction
) => Promise<NFTSale | null>;
35 changes: 24 additions & 11 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ import notifyDiscordSale, { getStatus } from "lib/discord/notifyDiscordSale";
import { Env, loadConfig } from "config";
import { Worker } from "workers/types";
import notifyNFTSalesWorker from "workers/notifyNFTSalesWorker";
import notifyMagicEdenNFTSalesWorker from "workers/notifyMagicEdenNFTSalesWorker";
import { parseNFTSale } from "lib/marketplaces";
import { ParsedTransactionWithMeta } from "@solana/web3.js";
import notifyTwitter from "lib/twitter/notifyTwitter";
@@ -109,19 +110,31 @@ import queue from "queue";
logger.log(`Ready on http://localhost:${port}`);
});

if (!subscriptions.length) {
logger.warn("No subscriptions loaded");
return;
let workers: Worker[] = [];
if (subscriptions.length) {
workers = subscriptions.map((s) => {
const project = {
discordChannelId: s.discordChannelId,
mintAddress: s.mintAddress,
};
const notifier = notifierFactory.create(project);
return notifyNFTSalesWorker(notifier, web3Conn, project);
});
}

const workers: Worker[] = subscriptions.map((s) => {
const project = {
discordChannelId: s.discordChannelId,
mintAddress: s.mintAddress,
};
const notifier = notifierFactory.create(project);
return notifyNFTSalesWorker(notifier, web3Conn, project);
});
if (config.magicEdenConfig.collection) {
const notifier = notifierFactory.create({
discordChannelId: config.magicEdenConfig?.discordChannelId,
mintAddress: "",
});
workers.push(
notifyMagicEdenNFTSalesWorker(
notifier,
web3Conn,
config.magicEdenConfig
)
);
}

const _ = initWorkers(workers, () => {
// Add randomness between worker executions so the requests are not made all at once
81 changes: 81 additions & 0 deletions src/workers/notifyMagicEdenNFTSalesWorker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { NotificationType } from "lib/notifier";
import { MagicEdenConfig } from "config";
import { CollectionActivity } from "lib/marketplaces";

jest.mock("lib/solana/NFTData", () => {
return {
fetchNFTData: () => {
return {};
},
};
});

jest.mock("axios", () => {
return {
get: () => {
return {
data: [
{
type: "buyNow",
blockTime: 96292000003,
tokenMint: "tokenxxx2",
signature: "signature-2",
source: "xxx",
collection: "mlk",
buyer: "buyer-2",
seller: "seller-2",
price: 0.2,
},
{
type: "buyNow",
blockTime: 96292000001,
tokenMint: "tokenxxx1",
signature: "signature-1",
source: "xxx",
collection: "mlk",
buyer: "buyer-1",
seller: "seller-1",
price: 0.2,
},
] as CollectionActivity[],
};
},
};
});

import newWorker from "./notifyMagicEdenNFTSalesWorker";
import { Connection } from "@solana/web3.js";

describe("notifyMagicEdenNFTSalesWorker", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("execute", () => {
const notifier = {
notify: jest.fn(),
};

const conn = new Connection("https://test/");

test("on sale activity", async () => {
const config: MagicEdenConfig = {
url: "https://magiceden.io",
collection: "mlk",
discordChannelId: "",
};

const worker = newWorker(notifier, conn, config);

await worker.execute();

expect(notifier.notify.mock.calls.length).toEqual(2);
const expectedArgs = notifier.notify.mock.calls[0];
expect(expectedArgs[0]).toEqual(NotificationType.Sale);
expect(expectedArgs[1].buyer).toEqual("buyer-1"); // Should fires the earliest sale first

await worker.execute();

expect(notifier.notify.mock.calls.length).toEqual(2);
});
});
});
120 changes: 120 additions & 0 deletions src/workers/notifyMagicEdenNFTSalesWorker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import axios from "axios";
import { MagicEdenConfig } from "config";
import { NFTSale, SaleMethod } from "lib/marketplaces";
import MagicEden from "lib/marketplaces/magicEden";
import { fetchNFTData } from "lib/solana/NFTData";
import { Worker } from "./types";
import { Connection } from "@solana/web3.js";
import logger from "lib/logger";
import { NotificationType, Notifier } from "lib/notifier";

export interface CollectionActivity {
signature: string;
type: string;
source: string;
tokenMint: string;
collection: string;
slot: number;
blockTime: number;
buyer: string;
buyerReferral: string;
seller?: any;
sellerReferral: string;
price: number;
}

function newNotificationsTracker(limit: number = 50) {
let notifiedTxs: string[] = [];

return {
alreadyNotified(tx: string) {
return notifiedTxs.includes(tx);
},
trackNotifiedTx(tx: string) {
notifiedTxs = [tx, ...notifiedTxs];
if (notifiedTxs.length > limit) {
notifiedTxs.pop();
}
},
};
}

export default function newWorker(
notifier: Notifier,
web3Conn: Connection,
config: MagicEdenConfig
): Worker {
const timestamp = Date.now();
let notifyAfter = new Date(timestamp);

/**
* Keep track of the latest notifications, so we don't notify them again
*/
const latestNotifications = newNotificationsTracker();

return {
async execute() {
let activities: CollectionActivity[] = [];
try {
// Reference: https://api.magiceden.dev/#95fed531-fd1f-4cbb-8137-30e0f2294cd7
const res = await axios.get(
`${config.url}/collections/${config.collection}/activities?offset=0&limit=100`
);
activities = res.data as CollectionActivity[];
} catch (e) {
logger.error(e);
return;
}

const sortByEarliest = activities.sort(
(a: CollectionActivity, b: CollectionActivity) => {
return a.blockTime - b.blockTime;
}
);

for (let i = 0; i < sortByEarliest.length; i++) {
const activity = sortByEarliest[i];
if (activity.type !== "buyNow") {
continue;
}

const nftData = await fetchNFTData(web3Conn, activity.tokenMint);
if (!nftData) {
return;
}
const nftSale: NFTSale = {
transaction: activity.signature,
soldAt: new Date((activity.blockTime || 0) * 1000),
seller: activity.seller,
buyer: activity.buyer,
token: activity.tokenMint,
method: SaleMethod.Direct,
marketplace: MagicEden,
transfers: [],
nftData,
getPriceInLamport() {
return activity.price / 1000000;
},
getPriceInSOL() {
return activity.price;
},
};

if (notifyAfter > nftSale.soldAt) {
return;
}

// Don't notify if transaction was previously notified.
if (latestNotifications.alreadyNotified(nftSale.transaction)) {
logger.warn(`Duplicate tx ignored: ${nftSale.transaction}`);
return;
}

await notifier.notify(NotificationType.Sale, nftSale);

latestNotifications.trackNotifiedTx(nftSale.transaction);
notifyAfter = nftSale.soldAt;
}
},
};
}

0 comments on commit e777105

Please sign in to comment.